room.html burger btn + 5-fan; universal landscape btn refactor; kit_bag_dialog vertical bar — TDD
Major feature push staging room.html for sprint A.8 — adds the burger btn + fan-of-five sub-btns affordance, then rotates the whole landscape btn layout to make room for it. The landscape refactor is universal (not .room-page-scoped) so every page that hosts these btns reads consistently.
## Burger btn + fan-of-five (room.html only)
`templates/apps/gameboard/_partials/_room_burger.html` (NEW) — `#id_burger_btn` (.fa-burger) + `#id_burger_fan` containing 5 sub-btns: `#id_voice_btn` (headset), `#id_sky_btn` (cloud), `#id_earth_btn` (earth-americas), `#id_sea_btn` (bridge-water), `#id_text_btn` (keyboard). Pure scaffolding — no click handlers in this sprint; wire-up lands later as each surface matures.
`apps/epic/static/apps/epic/burger-btn.js` (NEW) — toggle `.active` on click; Escape + click-outside close; opening burger auto-closes the kit dialog (`#id_kit_bag_dialog`) + the bud slide-out panel (`html.bud-open`) by dispatching a click to the owning btn (routes through that btn's own toggle/close path — no fetch on close). `bindBurger()` returns an `AbortController` so test code (+ any future re-bind callers) can detach all listeners cleanly via `ac.abort()`.
`static_src/scss/_burger.scss` (NEW) — burger sits parallel to gear-above-kit but on the bud-side. Portrait: bottom:4.2rem; left:0.5rem (above #id_bud_btn). Landscape: bottom:0.5rem; right:4.2rem (to the LEFT of #id_bud_btn). Fan is a CSS radial menu w. each sub-btn's `--angle = --base + --i * 30deg`. Portrait `--base: 0deg` (arc 12→4 o'clock), landscape `--base: -90deg` (arc 9→1 o'clock). Sub-btn radius `--r: 7.75rem`. Indices: voice=0, sky=1, earth=2, sea=3, text=4 (user-spec'd clockwise order).
room.html includes the partial + the script; the burger renders unconditionally regardless of `gate_status` / `table_status`.
## Universal landscape btn refactor
Sprint replaces the prior centred-in-sidebar landscape arrangement w. a kit-at-top + bud-at-bottom + gear/burger as horizontal partners. Applies to every page in landscape (was scoped to .room-page in the first iteration).
`_game-kit.scss` — kit_btn landscape moves to top:0.5rem; right:0.5rem (was bottom:0.5rem centred in sidebar). The 0.5rem right literal (not the calc((--sidebar-w - 3rem)/2)=1rem) produces a 0.7rem edge-to-edge gap w. gear at right:4.2rem — matching the portrait gear-above-kit gap exactly.
`_bud.scss` — bud_btn landscape moves to bottom:0.5rem; right:0.5rem (was top:0.5rem centred in sidebar). Same 0.5rem literal as kit_btn. bud_panel #id_recipient relocates to bottom:0.5rem (was top:0.5rem) + transform-origin flips to right center. .bud-suggestions rise upward from above the panel (bottom:4rem) instead of dropping from below.
`_applets.scss` — .gear-btn landscape moves to top:0.5rem; right:4.2rem (was centred bottom:3.95rem). All applet menus anchor at top:2.6rem; right:4.2rem (beneath the gear's leftward arc) extending DOWN-LEFT into the viewport. #id_room_menu joins the shared portrait position list (was bespoke in _room.scss).
`_room.scss` — bespoke #id_room_menu rule deleted entirely. The menu now inherits %applet-menu + the shared portrait position list — same chrome + behaviour as #id_post_menu / #id_billscroll_menu. Earlier iteration tried flex-direction:row in landscape; reverted per user request — "lose all scss specificity" wins.
`_card-deck.scss` — obsolete `#id_room_menu { right: 2.5rem; }` override in the XL+landscape block deleted. Was a same-specificity hack to beat _applets.scss's old centred position; no longer needed w. the consolidated rule.
## kit_bag_dialog vertical bar in landscape
`_game-kit.scss` — when open in landscape, dialog covers the right sidebar (top:0; bottom:0; right:0; width: var(--sidebar-w)). Slides in from off-viewport right by animating max-width 0 → var(--sidebar-w). Opaque bg (rgba(--priUser, 1) — was 0.97). z-index: 319 (above burger at 318) so it lands in front of the burger btn when open. Top-edge border → left-edge border.
Inner content flips to `flex-direction: column-reverse` so DOM order Deck→Dice→Trinket→Tokens paints visually bottom→top. .kit-bag-section also column-reverse → icon row above label. .kit-bag-label drops vertical-rl + the rotate(180deg) scaleX(1.3) transform, reads horizontally. .kit-bag-row--scroll flips to column + overflow-y for the Tokens scrollable row.
`game-kit.js attachTooltip()` — 2-axis tooltip clamp matching sky-wheel.js + wallet.js's pattern. Horizontal: left edge stays within [1rem, viewport-ttW-1rem]. Vertical: prefer ABOVE the element; flip BELOW when tooltip is too tall to fit above (e.g. landscape kit bar w. Tokens row near top). Resets top/bottom on mouseleave so next show measures fresh.
## Tests
`apps/epic/tests/integrated/test_views.py` (+1 class, 6 ITs) — RoomBurgerBtnRenderTest: burger_btn renders, fan container renders, 5 sub-btns w. correct ids, icons match spec, burger-btn.js loaded, burger persists thru table_status.
`static_src/tests/BurgerSpec.js` (NEW, 19 Jasmine specs) — bindBurger() returns AbortController; click toggle; Escape close; click-outside close; opening burger closes kit dialog + bud panel when set; AbortController teardown removes listeners.
All 1356 IT+UT green (+6 new from RoomBurgerBtnRenderTest, was 1350). Jasmine suite green (was 220-something specs, +19 BurgerSpec).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,13 +64,42 @@
|
||||
if (!tooltip) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.left = rect.left + 'px';
|
||||
// Show first so offsetWidth/offsetHeight measure real layout,
|
||||
// then clamp both axes so the tooltip never bleeds past the
|
||||
// viewport. Same shape as sky-wheel.js + wallet.js: 1rem inset
|
||||
// margin on every edge.
|
||||
tooltip.style.display = 'block';
|
||||
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
var ttW = tooltip.offsetWidth;
|
||||
var ttH = tooltip.offsetHeight;
|
||||
|
||||
// Horizontal clamp — left edge stays within [rem, viewport-ttW-rem].
|
||||
var minLeft = rem;
|
||||
var maxLeft = window.innerWidth - ttW - rem;
|
||||
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
|
||||
tooltip.style.left = clampedLeft + 'px';
|
||||
|
||||
// Vertical: prefer ABOVE the element; flip BELOW when the
|
||||
// tooltip is too tall to fit above (e.g. in landscape where
|
||||
// the kit bag dialog runs along the top of the right sidebar
|
||||
// + tokens row anchors near the top of the viewport).
|
||||
var spaceAbove = rect.top - rem;
|
||||
if (ttH <= spaceAbove) {
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.top = '';
|
||||
} else {
|
||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||
tooltip.style.bottom = '';
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseleave', function () {
|
||||
var tooltip = el.querySelector('.tt');
|
||||
if (tooltip) tooltip.style.display = '';
|
||||
if (tooltip) {
|
||||
tooltip.style.display = '';
|
||||
// Reset positional props so the next show measures fresh.
|
||||
tooltip.style.top = '';
|
||||
tooltip.style.bottom = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
88
src/apps/epic/static/apps/epic/burger-btn.js
Normal file
88
src/apps/epic/static/apps/epic/burger-btn.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Burger btn + 5-fan menu on room.html. Pure scaffolding for now —
|
||||
// sub-btns have no click handlers in this sprint. Behaviour owned here:
|
||||
// • Toggle #id_burger_btn.active on click.
|
||||
// • Closing burger via re-click / Escape / click-outside.
|
||||
// • Opening burger auto-closes the kit dialog (#id_kit_bag_dialog) and
|
||||
// the bud slide-out panel (html.bud-open) if either is open —
|
||||
// dispatched by clicking the owning btn, which routes through that
|
||||
// btn's own toggle/close path (no fetch on close).
|
||||
//
|
||||
// bindBurger() returns an AbortController so test code (and any future
|
||||
// re-bind callers) can detach all listeners cleanly via ac.abort().
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function bindBurger() {
|
||||
var btn = document.getElementById('id_burger_btn');
|
||||
var fan = document.getElementById('id_burger_fan');
|
||||
if (!btn || !fan) return null;
|
||||
|
||||
var ac = new AbortController();
|
||||
var sig = ac.signal;
|
||||
|
||||
function _isOpen() {
|
||||
return btn.classList.contains('active');
|
||||
}
|
||||
|
||||
function _closeKit() {
|
||||
var dialog = document.getElementById('id_kit_bag_dialog');
|
||||
var kitBtn = document.getElementById('id_kit_btn');
|
||||
if (dialog && dialog.hasAttribute('open') && kitBtn) {
|
||||
kitBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _closeBud() {
|
||||
var budBtn = document.getElementById('id_bud_btn');
|
||||
if (document.documentElement.classList.contains('bud-open') && budBtn) {
|
||||
budBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _open() {
|
||||
_closeKit();
|
||||
_closeBud();
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
fan.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function _close() {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
fan.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (_isOpen()) _close();
|
||||
else _open();
|
||||
}, { signal: sig });
|
||||
|
||||
fan.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
}, { signal: sig });
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && _isOpen()) _close();
|
||||
}, { signal: sig });
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!_isOpen()) return;
|
||||
if (btn.contains(e.target)) return;
|
||||
if (fan.contains(e.target)) return;
|
||||
_close();
|
||||
}, { signal: sig });
|
||||
|
||||
return ac;
|
||||
}
|
||||
|
||||
window.bindBurger = bindBurger;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bindBurger);
|
||||
} else {
|
||||
bindBurger();
|
||||
}
|
||||
}());
|
||||
@@ -2547,3 +2547,52 @@ class SeaDeckViewTest(TestCase):
|
||||
data = response.json()
|
||||
all_ids = {c["id"] for c in data["levity"]} | {c["id"] for c in data["gravity"]}
|
||||
self.assertNotIn(sig_card.id, all_ids)
|
||||
|
||||
|
||||
class RoomBurgerBtnRenderTest(TestCase):
|
||||
"""Burger btn + fan of 5 sub-btns renders on room.html unconditionally —
|
||||
from the gatekeeper state through every table_status. Sub-btns are
|
||||
pure scaffolding (no handlers in this sprint)."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="founder@test.io")
|
||||
self.room = Room.objects.create(name="Burger Room", owner=self.user)
|
||||
self.client.force_login(self.user)
|
||||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_burger_btn_renders(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, 'id="id_burger_btn"')
|
||||
self.assertContains(response, "fa-burger")
|
||||
|
||||
def test_burger_fan_container_renders(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, 'id="id_burger_fan"')
|
||||
|
||||
def test_five_fan_sub_btns_render_with_correct_ids(self):
|
||||
response = self.client.get(self.url)
|
||||
for btn_id in (
|
||||
"id_sky_btn", "id_earth_btn", "id_sea_btn",
|
||||
"id_voice_btn", "id_text_btn",
|
||||
):
|
||||
self.assertContains(response, f'id="{btn_id}"')
|
||||
|
||||
def test_fan_sub_btn_icons_match_spec(self):
|
||||
response = self.client.get(self.url)
|
||||
for icon in (
|
||||
"fa-cloud", "fa-earth-americas", "fa-bridge-water",
|
||||
"fa-headset", "fa-keyboard",
|
||||
):
|
||||
self.assertContains(response, icon)
|
||||
|
||||
def test_burger_btn_script_loaded(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "burger-btn.js")
|
||||
|
||||
def test_burger_persists_in_table_status_state(self):
|
||||
"""Burger renders past the gatekeeper too — confirmed for ROLE_SELECT."""
|
||||
self.room.gate_status = Room.OPEN
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, 'id="id_burger_btn"')
|
||||
|
||||
206
src/static/tests/BurgerSpec.js
Normal file
206
src/static/tests/BurgerSpec.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ── BurgerSpec.js ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for burger-btn.js — the room.html corner-menu btn + its 5-btn
|
||||
// fanning arc (sky, earth, sea, voice, text).
|
||||
//
|
||||
// DOM contract assumed by the module:
|
||||
// #id_burger_btn — the burger btn
|
||||
// #id_burger_fan — the fan container (sibling)
|
||||
// .burger-fan-btn — each sub-btn (5 of them)
|
||||
// #id_kit_btn — kit btn (optional)
|
||||
// #id_kit_bag_dialog — kit dialog (optional)
|
||||
// #id_bud_btn — bud btn (optional)
|
||||
//
|
||||
// Public API under test (window.bindBurger):
|
||||
// var ac = bindBurger(); // returns an AbortController for cleanup
|
||||
//
|
||||
// Behaviours:
|
||||
// • Click toggles .active class on #id_burger_btn.
|
||||
// • Escape closes when open.
|
||||
// • Click outside closes when open.
|
||||
// • Opening burger closes the kit dialog + the bud panel if either is open.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Burger", () => {
|
||||
let burgerBtn, fan, ac;
|
||||
|
||||
beforeEach(() => {
|
||||
burgerBtn = document.createElement("button");
|
||||
burgerBtn.id = "id_burger_btn";
|
||||
document.body.appendChild(burgerBtn);
|
||||
|
||||
fan = document.createElement("div");
|
||||
fan.id = "id_burger_fan";
|
||||
document.body.appendChild(fan);
|
||||
|
||||
ac = bindBurger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ac) ac.abort();
|
||||
burgerBtn.remove();
|
||||
fan.remove();
|
||||
// Clean up any test-added kit/bud elements + classes.
|
||||
["id_kit_btn", "id_kit_bag_dialog", "id_bud_btn"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.remove();
|
||||
});
|
||||
document.documentElement.classList.remove("bud-open");
|
||||
});
|
||||
|
||||
describe("bindBurger()", () => {
|
||||
it("returns an AbortController when DOM is present", () => {
|
||||
expect(ac).not.toBeNull();
|
||||
expect(ac.abort).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns null when #id_burger_btn is absent", () => {
|
||||
burgerBtn.remove();
|
||||
const ac2 = bindBurger();
|
||||
expect(ac2).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when #id_burger_fan is absent", () => {
|
||||
fan.remove();
|
||||
const ac2 = bindBurger();
|
||||
expect(ac2).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("click toggle", () => {
|
||||
it("first click adds .active to #id_burger_btn", () => {
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("first click sets aria-expanded='true'", () => {
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("first click sets fan aria-hidden='false'", () => {
|
||||
burgerBtn.click();
|
||||
expect(fan.getAttribute("aria-hidden")).toBe("false");
|
||||
});
|
||||
|
||||
it("second click removes .active (closes)", () => {
|
||||
burgerBtn.click();
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("second click resets aria-expanded='false'", () => {
|
||||
burgerBtn.click();
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Escape key", () => {
|
||||
it("closes burger when open", () => {
|
||||
burgerBtn.click();
|
||||
const evt = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
document.dispatchEvent(evt);
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("no-op when burger already closed", () => {
|
||||
const evt = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
document.dispatchEvent(evt);
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("click-outside", () => {
|
||||
it("closes burger on click outside btn + fan", () => {
|
||||
burgerBtn.click();
|
||||
const outsider = document.createElement("div");
|
||||
document.body.appendChild(outsider);
|
||||
outsider.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
outsider.remove();
|
||||
});
|
||||
|
||||
it("does NOT close on click inside fan", () => {
|
||||
burgerBtn.click();
|
||||
const subBtn = document.createElement("button");
|
||||
subBtn.className = "burger-fan-btn";
|
||||
fan.appendChild(subBtn);
|
||||
subBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening burger closes other corner panels", () => {
|
||||
it("clicks #id_kit_btn when kit dialog is open", () => {
|
||||
const kitBtn = document.createElement("button");
|
||||
kitBtn.id = "id_kit_btn";
|
||||
const kitClickSpy = jasmine.createSpy("kitClick");
|
||||
kitBtn.addEventListener("click", kitClickSpy);
|
||||
document.body.appendChild(kitBtn);
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.id = "id_kit_bag_dialog";
|
||||
dialog.setAttribute("open", "");
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
burgerBtn.click();
|
||||
expect(kitClickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT click #id_kit_btn when kit dialog is closed", () => {
|
||||
const kitBtn = document.createElement("button");
|
||||
kitBtn.id = "id_kit_btn";
|
||||
const kitClickSpy = jasmine.createSpy("kitClick");
|
||||
kitBtn.addEventListener("click", kitClickSpy);
|
||||
document.body.appendChild(kitBtn);
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.id = "id_kit_bag_dialog";
|
||||
// no open attribute
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
burgerBtn.click();
|
||||
expect(kitClickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicks #id_bud_btn when html.bud-open is set", () => {
|
||||
const budBtn = document.createElement("button");
|
||||
budBtn.id = "id_bud_btn";
|
||||
const budClickSpy = jasmine.createSpy("budClick");
|
||||
budBtn.addEventListener("click", budClickSpy);
|
||||
document.body.appendChild(budBtn);
|
||||
|
||||
document.documentElement.classList.add("bud-open");
|
||||
|
||||
burgerBtn.click();
|
||||
expect(budClickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT click #id_bud_btn when html.bud-open is absent", () => {
|
||||
const budBtn = document.createElement("button");
|
||||
budBtn.id = "id_bud_btn";
|
||||
const budClickSpy = jasmine.createSpy("budClick");
|
||||
budBtn.addEventListener("click", budClickSpy);
|
||||
document.body.appendChild(budBtn);
|
||||
|
||||
burgerBtn.click();
|
||||
expect(budClickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores missing kit/bud btns w.o. error", () => {
|
||||
// No kit, no bud — opening should still succeed.
|
||||
expect(() => burgerBtn.click()).not.toThrow();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbortController teardown", () => {
|
||||
it("detaches click handler when ac.abort() is called", () => {
|
||||
ac.abort();
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@
|
||||
<script src="NotePageSpec.js"></script>
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<script src="BurgerSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -42,6 +43,7 @@
|
||||
<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/epic/burger-btn.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/sky-wheel.js"></script>
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
#id_game_applet_menu,
|
||||
#id_game_kit_menu,
|
||||
#id_wallet_applet_menu,
|
||||
#id_room_menu,
|
||||
#id_post_menu,
|
||||
#id_billboard_applet_menu,
|
||||
#id_billscroll_menu,
|
||||
@@ -118,24 +119,28 @@
|
||||
z-index: 312;
|
||||
}
|
||||
|
||||
// In landscape: shift gear btn and applet menus into the footer-sidebar
|
||||
// column. Both gear-btn (3rem wide) and the menus are centred in the
|
||||
// `var(--sidebar-w)` slot via `right: calc((var(--sidebar-w) - 3rem) / 2)`,
|
||||
// which scales with the rem-fluid root — no per-breakpoint override.
|
||||
// In landscape: gear-btn relocates to the LEFT of kit_btn at the top of
|
||||
// the right sidebar (kit at right:0.5rem, gear at right:4.2rem — same
|
||||
// 3.7rem centre-to-centre delta as the portrait gear-above-kit stack,
|
||||
// rotated 90deg into the horizontal axis). Each page that hosts a page-
|
||||
// level `.gear-btn` gets the same anchor.
|
||||
//
|
||||
// Applet menus all anchor at top:2.1rem; right:4.2rem (beneath the gear's
|
||||
// leftward arc) and extend DOWN-LEFT. Vertical column flow stays the
|
||||
// default — the only menu that rotates aspect is #id_room_menu, which
|
||||
// carries that override in _room.scss (2-btn menu → row layout).
|
||||
@media (orientation: landscape) {
|
||||
.gameboard-page,
|
||||
.dashboard-page,
|
||||
.wallet-page,
|
||||
.room-page,
|
||||
.post-page,
|
||||
.billboard-page,
|
||||
.billscroll-page,
|
||||
.my-sea-page {
|
||||
> .gear-btn {
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
bottom: 3.95rem;
|
||||
top: auto;
|
||||
}
|
||||
.gameboard-page > .gear-btn,
|
||||
.dashboard-page > .gear-btn,
|
||||
.wallet-page > .gear-btn,
|
||||
.room-page > .gear-btn,
|
||||
.post-page > .gear-btn,
|
||||
.billboard-page > .gear-btn,
|
||||
.billscroll-page > .gear-btn,
|
||||
.my-sea-page > .gear-btn {
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
right: 4.2rem;
|
||||
}
|
||||
|
||||
#id_dash_applet_menu,
|
||||
@@ -147,9 +152,11 @@
|
||||
#id_billboard_applet_menu,
|
||||
#id_billscroll_menu,
|
||||
#id_my_sea_menu {
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
bottom: 6.6rem;
|
||||
top: auto;
|
||||
position: fixed;
|
||||
top: 2.6rem;
|
||||
right: 4.2rem;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
|
||||
// In landscape, the bud-btn moves to the UPPER-RIGHT corner of the
|
||||
// footer sidebar (top of the right sidebar). Centred within the
|
||||
// var(--sidebar-w) column. Mirrors kit-btn / gear-btn (bottom-right)
|
||||
// for the horizontal-centre formula, but anchors at top instead.
|
||||
// In landscape, the bud-btn lives at the BOTTOM-RIGHT corner of the
|
||||
// right sidebar (mirror of kit_btn at the TOP). Burger sits to its
|
||||
// LEFT in the same horizontal pair (kit/gear at top, bud/burger at
|
||||
// bottom). Anchored at right: 0.5rem (same as portrait) so the
|
||||
// burger-to-the-LEFT-of-bud gap reads as 0.7rem edge-to-edge,
|
||||
// matching the portrait burger-above-bud gap.
|
||||
@media (orientation: landscape) {
|
||||
left: auto;
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
right: 0.5rem;
|
||||
top: auto;
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
z-index: 318;
|
||||
@@ -64,12 +66,12 @@
|
||||
opacity: 0;
|
||||
|
||||
@media (orientation: landscape) {
|
||||
// Bud-btn lives at the top of the right sidebar in landscape, so
|
||||
// the panel slides out leftward from the right edge along the top.
|
||||
// Clear both fixed sidebars; transform-origin flips to right so
|
||||
// the closed state collapses into the bud-btn.
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
// Bud-btn lives at the BOTTOM of the right sidebar in landscape,
|
||||
// so the panel slides out leftward from the right edge along the
|
||||
// BOTTOM. Clear both fixed sidebars; transform-origin flips to
|
||||
// right so the closed state collapses into the bud-btn.
|
||||
top: auto;
|
||||
bottom: 0.5rem;
|
||||
left: calc(var(--sidebar-w) + 0.5rem);
|
||||
right: calc(var(--sidebar-w) + 0.5rem);
|
||||
transform-origin: right center;
|
||||
@@ -141,10 +143,11 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
||||
|
||||
@media (orientation: landscape) {
|
||||
// Panel now at top in landscape, so suggestions drop downward.
|
||||
top: 4rem;
|
||||
bottom: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
// Panel sits at the BOTTOM in landscape, so suggestions rise upward
|
||||
// from above the panel — shadow direction stays "up" (negative y).
|
||||
top: auto;
|
||||
bottom: 4rem;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
||||
left: calc(var(--sidebar-w) + 0.5rem);
|
||||
right: calc(var(--sidebar-w) + 0.5rem);
|
||||
}
|
||||
|
||||
121
src/static_src/scss/_burger.scss
Normal file
121
src/static_src/scss/_burger.scss
Normal file
@@ -0,0 +1,121 @@
|
||||
// ── Burger btn + fan-of-5 (room.html only) ───────────────────────────
|
||||
//
|
||||
// Sits parallel to .gear-btn-above-#id_kit_btn, but on the bud-side:
|
||||
// portrait : bottom-left, above #id_bud_btn
|
||||
// landscape: right sidebar, to the LEFT of #id_bud_btn (same horizontal
|
||||
// pair as gear ← kit at the top)
|
||||
//
|
||||
// Active state opens a fanning arc of 5 sub-btns 30deg apart, each
|
||||
// centred --r from the burger centre. R=7.75rem reads as a comfortable
|
||||
// spread w. ~0.4rem edge-to-edge gaps between adjacent sub-btns.
|
||||
// portrait : arc 12→4 o'clock (sky, earth, sea, voice, text)
|
||||
// landscape: arc 9→1 o'clock (same icon order, rotated CCW 90°)
|
||||
//
|
||||
// Sub-btns are pure scaffolding — no click handlers in this sprint.
|
||||
//
|
||||
// Landscape btn relocation rules for kit / gear / bud / kit_dialog / bud
|
||||
// panel / applet menus DON'T live in this partial — they're owned by
|
||||
// their respective `_game-kit.scss`, `_bud.scss`, `_applets.scss`,
|
||||
// `_room.scss` partials. _burger.scss only owns burger-specific styling.
|
||||
|
||||
#id_burger_btn {
|
||||
position: fixed;
|
||||
bottom: 4.2rem;
|
||||
left: 0.5rem;
|
||||
z-index: 318;
|
||||
font-size: 1.75rem;
|
||||
cursor: pointer;
|
||||
color: rgba(var(--secUser), 1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.active {
|
||||
color: rgba(var(--quaUser), 1);
|
||||
border-color: rgba(var(--quaUser), 1);
|
||||
}
|
||||
|
||||
// Landscape: burger sits to the LEFT of #id_bud_btn (same bottom
|
||||
// anchor as bud). Mirrors gear-to-the-LEFT-of-kit at the top of the
|
||||
// right sidebar (see _applets.scss for the gear placement).
|
||||
@media (orientation: landscape) {
|
||||
left: auto;
|
||||
right: 4.2rem; // bud_btn right (0.5) + 3.7rem centre-to-centre delta
|
||||
bottom: 0.5rem;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Fan anchor — sits at the burger centre. Sub-btns inside translate
|
||||
// outward radially when #id_burger_btn carries the .active class.
|
||||
#id_burger_fan {
|
||||
position: fixed;
|
||||
bottom: 5.7rem; // burger bottom (4.2) + half-btn (1.5)
|
||||
left: 2rem; // burger left (0.5) + half-btn (1.5)
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 317; // below #id_burger_btn so the burger stays clickable
|
||||
pointer-events: none;
|
||||
|
||||
@media (orientation: landscape) {
|
||||
left: auto;
|
||||
right: 5.7rem; // burger right (4.2) + half-btn (1.5) → burger centre
|
||||
bottom: 2rem; // burger bottom (0.5) + half-btn (1.5) → burger centre
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.burger-fan-btn {
|
||||
--r: 7.75rem;
|
||||
--i: 0;
|
||||
--base: 0deg; // portrait: arc starts at 12 o'clock
|
||||
--angle: calc(var(--base) + var(--i) * 30deg);
|
||||
|
||||
position: absolute;
|
||||
left: -1.5rem; // centre 3rem btn on parent's 0,0 origin
|
||||
top: -1.5rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
color: rgba(var(--secUser), 1);
|
||||
font-size: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
// Closed state — collapsed onto burger centre, invisible
|
||||
transform: rotate(var(--angle)) translateY(0) rotate(calc(-1 * var(--angle)));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.25s ease-out, opacity 0.2s ease;
|
||||
|
||||
// Landscape: arc rotates CCW 90° — start at 9 o'clock instead of 12.
|
||||
@media (orientation: landscape) {
|
||||
--base: -90deg;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-btn arc index (clockwise from arc start) — voice leads, then sky,
|
||||
// earth, sea, text. Portrait arc: voice at 12, text at 4 o'clock.
|
||||
// Landscape arc: voice at 9, text at 1 o'clock.
|
||||
#id_voice_btn { --i: 0; }
|
||||
#id_sky_btn { --i: 1; }
|
||||
#id_earth_btn { --i: 2; }
|
||||
#id_sea_btn { --i: 3; }
|
||||
#id_text_btn { --i: 4; }
|
||||
|
||||
// Open state — sub-btns swing out to their arc positions.
|
||||
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn {
|
||||
transform: rotate(var(--angle)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--angle)));
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -1456,9 +1456,6 @@ html:has(.sig-backdrop) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
// Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss
|
||||
// XL block because _card-deck.scss is imported after _applets.scss. Re-declare here to win the cascade.
|
||||
#id_room_menu { right: 2.5rem; }
|
||||
}
|
||||
|
||||
// ─── My Sign picker grid ────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
|
||||
// In landscape, centre the 3rem-wide btn within the var(--sidebar-w)
|
||||
// footer sidebar — matches the bud-btn (bottom-left) + gear-btn (above
|
||||
// kit-btn) formula. The clamp on the root rem means the sidebar scales
|
||||
// fluidly, so this single rule covers all viewports without a per-
|
||||
// breakpoint override.
|
||||
// In landscape, kit btn relocates to the TOP of the right sidebar
|
||||
// — anchored at right: 0.5rem (same as portrait), so the gear-to-the-
|
||||
// LEFT-of-kit gap (in _applets.scss) reads as 0.7rem edge-to-edge,
|
||||
// matching portrait's vertical gear-above-kit gap. Sidebar-centring
|
||||
// via calc((--sidebar-w - 3rem) / 2) = 1rem would tighten that gap
|
||||
// to 0.2rem, which reads visually wider than portrait.
|
||||
@media (orientation: landscape) {
|
||||
right: calc((var(--sidebar-w) - 3rem) / 2);
|
||||
bottom: 0.5rem;
|
||||
top: auto;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
z-index: 318;
|
||||
@@ -47,16 +48,13 @@
|
||||
border: none;
|
||||
border-top: 0.1rem solid rgba(var(--quaUser), 1);
|
||||
background: rgba(var(--priUser), 0.97);
|
||||
z-index: 316;
|
||||
// Above the burger btn (z-318) so the dialog covers it when open in
|
||||
// BOTH orientations — burger sits in the bottom-left corner in
|
||||
// portrait + the right sidebar in landscape; the dialog lands atop.
|
||||
z-index: 319;
|
||||
overflow: hidden;
|
||||
|
||||
@media (orientation: landscape) {
|
||||
$sidebar-w: 4rem;
|
||||
// left: $sidebar-w;
|
||||
right: $sidebar-w;
|
||||
z-index: 316;
|
||||
}
|
||||
// Closed state
|
||||
// Closed state — portrait: grow from below the footer (max-height)
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
transition: max-height 0.25s ease-out, visibility 0s 0.25s;
|
||||
@@ -71,6 +69,49 @@
|
||||
align-items: center;
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
// In landscape, the dialog slides in from off-viewport to the RIGHT,
|
||||
// landing ATOP the right sidebar (covers kit + gear + burger + bud
|
||||
// when open). Full viewport height. Width matches sidebar-w so the
|
||||
// closed→open width animation reads like the portrait grow-UPWARD-
|
||||
// from-bottom rotated 90° — closed has zero width, open spans the
|
||||
// sidebar column from right edge inward.
|
||||
@media (orientation: landscape) {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: var(--sidebar-w);
|
||||
height: auto;
|
||||
|
||||
// Opaque in landscape — the dialog covers the sidebar btns + the
|
||||
// burger + bud area when open; transparency would let those btns
|
||||
// bleed through the dialog content.
|
||||
background: rgba(var(--priUser), 1);
|
||||
|
||||
// Closed state — width animates from 0 (grows leftward from right)
|
||||
max-width: 0;
|
||||
max-height: none;
|
||||
transition: max-width 0.25s ease-out, visibility 0s 0.25s;
|
||||
|
||||
// Top-edge accent → left-edge accent (border on the aperture-facing side)
|
||||
border-top: none;
|
||||
border-left: 0.1rem solid rgba(var(--quaUser), 1);
|
||||
|
||||
&[open] {
|
||||
max-width: var(--sidebar-w);
|
||||
max-height: none;
|
||||
transition: max-width 0.25s ease-out, visibility 0s;
|
||||
display: flex !important;
|
||||
// DOM order is Deck→Dice→Trinket→Tokens; column-reverse paints
|
||||
// them visually bottom→top so Deck rests at the bottom of the
|
||||
// bar and Tokens at the top.
|
||||
flex-direction: column-reverse;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kit-bag-section {
|
||||
@@ -79,6 +120,14 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Landscape — stack icon row ABOVE label. DOM order is label → row;
|
||||
// column-reverse paints row at top + label at bottom (icon above title).
|
||||
@media (orientation: landscape) {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-bag-label {
|
||||
@@ -92,6 +141,17 @@
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg) scaleX(1.3);
|
||||
padding: 0 0.25rem 0 0.5rem;
|
||||
|
||||
// Landscape — undo vertical writing-mode + rotation; label reads
|
||||
// horizontally beneath its icon in the column-reverse section.
|
||||
@media (orientation: landscape) {
|
||||
writing-mode: horizontal-tb;
|
||||
text-orientation: mixed;
|
||||
transform: none;
|
||||
padding: 0;
|
||||
font-size: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-bag-row {
|
||||
@@ -156,6 +216,14 @@
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
|
||||
// Landscape — the dialog runs vertically, so the Tokens row also
|
||||
// flips to a vertical column + scrolls along the y-axis instead.
|
||||
@media (orientation: landscape) {
|
||||
flex-direction: column;
|
||||
overflow-x: visible;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.kit-bag-empty {
|
||||
|
||||
@@ -13,24 +13,6 @@ $gate-line: 2px;
|
||||
}
|
||||
|
||||
|
||||
#id_room_menu {
|
||||
position: fixed;
|
||||
bottom: 6.6rem;
|
||||
right: 0.5rem;
|
||||
z-index: 314;
|
||||
background-color: rgba(var(--priUser), 0.95);
|
||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||
box-shadow:
|
||||
0 0 0.5rem rgba(var(--secUser), 0.75),
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25)
|
||||
;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Scroll-lock when gate is open. Uses html (not body) to avoid CSS overflow
|
||||
// propagation quirk on Linux headless Firefox where body overflow:hidden can
|
||||
// disrupt pointer events on position:fixed descendants.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@import 'tooltips';
|
||||
@import 'game-kit';
|
||||
@import 'bud';
|
||||
@import 'burger';
|
||||
@import 'wallet-tokens';
|
||||
|
||||
|
||||
|
||||
206
src/static_src/tests/BurgerSpec.js
Normal file
206
src/static_src/tests/BurgerSpec.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ── BurgerSpec.js ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for burger-btn.js — the room.html corner-menu btn + its 5-btn
|
||||
// fanning arc (sky, earth, sea, voice, text).
|
||||
//
|
||||
// DOM contract assumed by the module:
|
||||
// #id_burger_btn — the burger btn
|
||||
// #id_burger_fan — the fan container (sibling)
|
||||
// .burger-fan-btn — each sub-btn (5 of them)
|
||||
// #id_kit_btn — kit btn (optional)
|
||||
// #id_kit_bag_dialog — kit dialog (optional)
|
||||
// #id_bud_btn — bud btn (optional)
|
||||
//
|
||||
// Public API under test (window.bindBurger):
|
||||
// var ac = bindBurger(); // returns an AbortController for cleanup
|
||||
//
|
||||
// Behaviours:
|
||||
// • Click toggles .active class on #id_burger_btn.
|
||||
// • Escape closes when open.
|
||||
// • Click outside closes when open.
|
||||
// • Opening burger closes the kit dialog + the bud panel if either is open.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Burger", () => {
|
||||
let burgerBtn, fan, ac;
|
||||
|
||||
beforeEach(() => {
|
||||
burgerBtn = document.createElement("button");
|
||||
burgerBtn.id = "id_burger_btn";
|
||||
document.body.appendChild(burgerBtn);
|
||||
|
||||
fan = document.createElement("div");
|
||||
fan.id = "id_burger_fan";
|
||||
document.body.appendChild(fan);
|
||||
|
||||
ac = bindBurger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ac) ac.abort();
|
||||
burgerBtn.remove();
|
||||
fan.remove();
|
||||
// Clean up any test-added kit/bud elements + classes.
|
||||
["id_kit_btn", "id_kit_bag_dialog", "id_bud_btn"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.remove();
|
||||
});
|
||||
document.documentElement.classList.remove("bud-open");
|
||||
});
|
||||
|
||||
describe("bindBurger()", () => {
|
||||
it("returns an AbortController when DOM is present", () => {
|
||||
expect(ac).not.toBeNull();
|
||||
expect(ac.abort).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns null when #id_burger_btn is absent", () => {
|
||||
burgerBtn.remove();
|
||||
const ac2 = bindBurger();
|
||||
expect(ac2).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when #id_burger_fan is absent", () => {
|
||||
fan.remove();
|
||||
const ac2 = bindBurger();
|
||||
expect(ac2).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("click toggle", () => {
|
||||
it("first click adds .active to #id_burger_btn", () => {
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("first click sets aria-expanded='true'", () => {
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("first click sets fan aria-hidden='false'", () => {
|
||||
burgerBtn.click();
|
||||
expect(fan.getAttribute("aria-hidden")).toBe("false");
|
||||
});
|
||||
|
||||
it("second click removes .active (closes)", () => {
|
||||
burgerBtn.click();
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("second click resets aria-expanded='false'", () => {
|
||||
burgerBtn.click();
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Escape key", () => {
|
||||
it("closes burger when open", () => {
|
||||
burgerBtn.click();
|
||||
const evt = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
document.dispatchEvent(evt);
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("no-op when burger already closed", () => {
|
||||
const evt = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
document.dispatchEvent(evt);
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("click-outside", () => {
|
||||
it("closes burger on click outside btn + fan", () => {
|
||||
burgerBtn.click();
|
||||
const outsider = document.createElement("div");
|
||||
document.body.appendChild(outsider);
|
||||
outsider.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
outsider.remove();
|
||||
});
|
||||
|
||||
it("does NOT close on click inside fan", () => {
|
||||
burgerBtn.click();
|
||||
const subBtn = document.createElement("button");
|
||||
subBtn.className = "burger-fan-btn";
|
||||
fan.appendChild(subBtn);
|
||||
subBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening burger closes other corner panels", () => {
|
||||
it("clicks #id_kit_btn when kit dialog is open", () => {
|
||||
const kitBtn = document.createElement("button");
|
||||
kitBtn.id = "id_kit_btn";
|
||||
const kitClickSpy = jasmine.createSpy("kitClick");
|
||||
kitBtn.addEventListener("click", kitClickSpy);
|
||||
document.body.appendChild(kitBtn);
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.id = "id_kit_bag_dialog";
|
||||
dialog.setAttribute("open", "");
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
burgerBtn.click();
|
||||
expect(kitClickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT click #id_kit_btn when kit dialog is closed", () => {
|
||||
const kitBtn = document.createElement("button");
|
||||
kitBtn.id = "id_kit_btn";
|
||||
const kitClickSpy = jasmine.createSpy("kitClick");
|
||||
kitBtn.addEventListener("click", kitClickSpy);
|
||||
document.body.appendChild(kitBtn);
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.id = "id_kit_bag_dialog";
|
||||
// no open attribute
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
burgerBtn.click();
|
||||
expect(kitClickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicks #id_bud_btn when html.bud-open is set", () => {
|
||||
const budBtn = document.createElement("button");
|
||||
budBtn.id = "id_bud_btn";
|
||||
const budClickSpy = jasmine.createSpy("budClick");
|
||||
budBtn.addEventListener("click", budClickSpy);
|
||||
document.body.appendChild(budBtn);
|
||||
|
||||
document.documentElement.classList.add("bud-open");
|
||||
|
||||
burgerBtn.click();
|
||||
expect(budClickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT click #id_bud_btn when html.bud-open is absent", () => {
|
||||
const budBtn = document.createElement("button");
|
||||
budBtn.id = "id_bud_btn";
|
||||
const budClickSpy = jasmine.createSpy("budClick");
|
||||
budBtn.addEventListener("click", budClickSpy);
|
||||
document.body.appendChild(budBtn);
|
||||
|
||||
burgerBtn.click();
|
||||
expect(budClickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores missing kit/bud btns w.o. error", () => {
|
||||
// No kit, no bud — opening should still succeed.
|
||||
expect(() => burgerBtn.click()).not.toThrow();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbortController teardown", () => {
|
||||
it("detaches click handler when ac.abort() is called", () => {
|
||||
ac.abort();
|
||||
burgerBtn.click();
|
||||
expect(burgerBtn.classList.contains("active")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@
|
||||
<script src="NotePageSpec.js"></script>
|
||||
<script src="RowLockSpec.js"></script>
|
||||
<script src="WalletShopSpec.js"></script>
|
||||
<script src="BurgerSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/applets/row-lock.js"></script>
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
@@ -42,6 +43,7 @@
|
||||
<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/epic/burger-btn.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/sky-wheel.js"></script>
|
||||
|
||||
24
src/templates/apps/gameboard/_partials/_room_burger.html
Normal file
24
src/templates/apps/gameboard/_partials/_room_burger.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{# Burger btn + fan of 5 sub-btns for room.html. Renders unconditionally #}
|
||||
{# above #id_bud_btn (portrait: bottom-left; landscape: right sidebar #}
|
||||
{# bottom). Sub-btns are pure scaffolding for now — no click handlers. #}
|
||||
{# Click handlers + targets land in later sprints as each surface matures. #}
|
||||
<button id="id_burger_btn" type="button" aria-label="Open burger menu" aria-expanded="false">
|
||||
<i class="fa-solid fa-burger"></i>
|
||||
</button>
|
||||
<div id="id_burger_fan" aria-hidden="true">
|
||||
<button id="id_sky_btn" type="button" class="burger-fan-btn" aria-label="Sky">
|
||||
<i class="fa-solid fa-cloud"></i>
|
||||
</button>
|
||||
<button id="id_earth_btn" type="button" class="burger-fan-btn" aria-label="Earth">
|
||||
<i class="fa-solid fa-earth-americas"></i>
|
||||
</button>
|
||||
<button id="id_sea_btn" type="button" class="burger-fan-btn" aria-label="Sea">
|
||||
<i class="fa-solid fa-bridge-water"></i>
|
||||
</button>
|
||||
<button id="id_voice_btn" type="button" class="burger-fan-btn" aria-label="Voice">
|
||||
<i class="fa-solid fa-headset"></i>
|
||||
</button>
|
||||
<button id="id_text_btn" type="button" class="burger-fan-btn" aria-label="Text">
|
||||
<i class="fa-solid fa-keyboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -119,6 +119,7 @@
|
||||
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||
{% include "apps/gameboard/_partials/_room_burger.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -135,4 +136,5 @@
|
||||
<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>
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
Reference in New Issue
Block a user