426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
|
|
// ── TraySpec.js ───────────────────────────────────────────────────────────────
|
||
|
|
//
|
||
|
|
// Unit specs for tray.js — the per-seat, per-room slide-out panel anchored
|
||
|
|
// to the right edge of the viewport.
|
||
|
|
//
|
||
|
|
// DOM contract assumed by the module:
|
||
|
|
// #id_tray_wrap — outermost container; JS sets style.left for positioning
|
||
|
|
// #id_tray_btn — the drawer-handle button
|
||
|
|
// #id_tray — the tray panel (hidden by default)
|
||
|
|
//
|
||
|
|
// Public API under test:
|
||
|
|
// Tray.init() — compute bounds, apply vertical bounds, attach listeners
|
||
|
|
// Tray.open() — reveal tray, animate wrap to minLeft
|
||
|
|
// Tray.close() — hide tray, animate wrap to maxLeft
|
||
|
|
// Tray.isOpen() — state predicate
|
||
|
|
// Tray.reset() — restore initial state (for afterEach)
|
||
|
|
//
|
||
|
|
// Drag model: tray follows pointer in real-time; position persists on release.
|
||
|
|
// Any leftward drag opens the tray.
|
||
|
|
// Drag > 10px suppresses the subsequent click event.
|
||
|
|
//
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe("Tray", () => {
|
||
|
|
let btn, tray, wrap;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
wrap = document.createElement("div");
|
||
|
|
wrap.id = "id_tray_wrap";
|
||
|
|
|
||
|
|
btn = document.createElement("button");
|
||
|
|
btn.id = "id_tray_btn";
|
||
|
|
|
||
|
|
tray = document.createElement("div");
|
||
|
|
tray.id = "id_tray";
|
||
|
|
tray.style.display = "none";
|
||
|
|
|
||
|
|
wrap.appendChild(btn);
|
||
|
|
document.body.appendChild(wrap);
|
||
|
|
document.body.appendChild(tray);
|
||
|
|
|
||
|
|
Tray._testSetLandscape(false); // force portrait regardless of window size
|
||
|
|
Tray.init();
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
Tray.reset();
|
||
|
|
wrap.remove();
|
||
|
|
tray.remove();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// open() //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("open()", () => {
|
||
|
|
it("makes #id_tray visible", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(tray.style.display).not.toBe("none");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("adds .open to #id_tray_btn", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(btn.classList.contains("open")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("sets wrap left to minLeft (0)", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(wrap.style.left).toBe("0px");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("calling open() twice does not duplicate .open", () => {
|
||
|
|
Tray.open();
|
||
|
|
Tray.open();
|
||
|
|
const openCount = btn.className.split(" ").filter(c => c === "open").length;
|
||
|
|
expect(openCount).toBe(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// close() //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("close()", () => {
|
||
|
|
beforeEach(() => Tray.open());
|
||
|
|
|
||
|
|
it("hides #id_tray after slide + snap both complete", () => {
|
||
|
|
Tray.close();
|
||
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||
|
|
wrap.dispatchEvent(new Event("animationend"));
|
||
|
|
expect(tray.style.display).toBe("none");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("adds .snap to wrap after slide transition completes", () => {
|
||
|
|
Tray.close();
|
||
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||
|
|
expect(wrap.classList.contains("snap")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .snap from wrap once animationend fires", () => {
|
||
|
|
Tray.close();
|
||
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||
|
|
wrap.dispatchEvent(new Event("animationend"));
|
||
|
|
expect(wrap.classList.contains("snap")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .open from #id_tray_btn", () => {
|
||
|
|
Tray.close();
|
||
|
|
expect(btn.classList.contains("open")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("sets wrap left to maxLeft", () => {
|
||
|
|
Tray.close();
|
||
|
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not throw if already closed", () => {
|
||
|
|
Tray.close();
|
||
|
|
expect(() => Tray.close()).not.toThrow();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// isOpen() //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("isOpen()", () => {
|
||
|
|
it("returns false by default", () => {
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns true after open()", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(Tray.isOpen()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns false after close()", () => {
|
||
|
|
Tray.open();
|
||
|
|
Tray.close();
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// Click when closed — wobble wrap, do not open //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("clicking btn when closed", () => {
|
||
|
|
it("adds .wobble to wrap", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(wrap.classList.contains("wobble")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not open the tray", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .wobble once animationend fires on wrap", () => {
|
||
|
|
btn.click();
|
||
|
|
wrap.dispatchEvent(new Event("animationend"));
|
||
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// Click when open — close, no wobble //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("clicking btn when open", () => {
|
||
|
|
beforeEach(() => Tray.open());
|
||
|
|
|
||
|
|
it("closes the tray", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not add .wobble", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// Drag interaction — continuous positioning //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("drag interaction", () => {
|
||
|
|
function simulateDrag(deltaX) {
|
||
|
|
const startX = 800;
|
||
|
|
btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true }));
|
||
|
|
btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true }));
|
||
|
|
btn.dispatchEvent(new PointerEvent("pointerup", { clientX: startX + deltaX, bubbles: true }));
|
||
|
|
}
|
||
|
|
|
||
|
|
it("dragging left opens the tray", () => {
|
||
|
|
simulateDrag(-60);
|
||
|
|
expect(Tray.isOpen()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("any leftward drag opens the tray", () => {
|
||
|
|
simulateDrag(-20);
|
||
|
|
expect(Tray.isOpen()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("dragging right does not open the tray", () => {
|
||
|
|
simulateDrag(100);
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("drag > 10px suppresses the subsequent click", () => {
|
||
|
|
simulateDrag(-60);
|
||
|
|
btn.click(); // should be swallowed — tray stays open
|
||
|
|
expect(Tray.isOpen()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not add .wobble during drag", () => {
|
||
|
|
simulateDrag(-60);
|
||
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// Landscape mode — Y-axis drag, top-positioned wrap //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("landscape mode", () => {
|
||
|
|
// Re-init in landscape after the portrait init from outer beforeEach.
|
||
|
|
beforeEach(() => {
|
||
|
|
Tray.reset();
|
||
|
|
Tray._testSetLandscape(true);
|
||
|
|
Tray.init();
|
||
|
|
});
|
||
|
|
|
||
|
|
function simulateDragY(deltaY) {
|
||
|
|
const startY = 50;
|
||
|
|
btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true }));
|
||
|
|
btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
||
|
|
btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── open() in landscape ─────────────────────────────────────────── //
|
||
|
|
|
||
|
|
describe("open()", () => {
|
||
|
|
it("makes #id_tray visible", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(tray.style.display).not.toBe("none");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("adds .open to #id_tray_btn", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(btn.classList.contains("open")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("positions wrap via style.top, not style.left", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(wrap.style.top).not.toBe("");
|
||
|
|
expect(wrap.style.left).toBe("");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── close() in landscape ────────────────────────────────────────── //
|
||
|
|
|
||
|
|
describe("close()", () => {
|
||
|
|
beforeEach(() => Tray.open());
|
||
|
|
|
||
|
|
it("closes the tray (display not toggled in landscape)", () => {
|
||
|
|
Tray.close();
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .open from #id_tray_btn", () => {
|
||
|
|
Tray.close();
|
||
|
|
expect(btn.classList.contains("open")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("closed top is less than open top (wrap slides up to close)", () => {
|
||
|
|
const openTop = parseInt(wrap.style.top, 10);
|
||
|
|
Tray.close();
|
||
|
|
const closedTop = parseInt(wrap.style.top, 10);
|
||
|
|
expect(closedTop).toBeLessThan(openTop);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("adds .snap to wrap after top transition completes", () => {
|
||
|
|
Tray.close();
|
||
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
||
|
|
expect(wrap.classList.contains("snap")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .snap from wrap once animationend fires", () => {
|
||
|
|
Tray.close();
|
||
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
||
|
|
wrap.dispatchEvent(new Event("animationend"));
|
||
|
|
expect(wrap.classList.contains("snap")).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── drag — Y axis ──────────────────────────────────────────────── //
|
||
|
|
|
||
|
|
describe("drag interaction", () => {
|
||
|
|
it("dragging down opens the tray", () => {
|
||
|
|
simulateDragY(100);
|
||
|
|
expect(Tray.isOpen()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("dragging up does not open the tray", () => {
|
||
|
|
simulateDragY(-100);
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("drag > 10px downward suppresses subsequent click", () => {
|
||
|
|
simulateDragY(100);
|
||
|
|
btn.click(); // should be swallowed — tray stays open
|
||
|
|
expect(Tray.isOpen()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not set style.left (Y axis only)", () => {
|
||
|
|
simulateDragY(100);
|
||
|
|
expect(wrap.style.left).toBe("");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not add .wobble during drag", () => {
|
||
|
|
simulateDragY(100);
|
||
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── click when closed — wobble, no open ───────────────────────── //
|
||
|
|
|
||
|
|
describe("clicking btn when closed", () => {
|
||
|
|
it("adds .wobble to wrap", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(wrap.classList.contains("wobble")).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not open the tray", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── click when open — close ────────────────────────────────────── //
|
||
|
|
|
||
|
|
describe("clicking btn when open", () => {
|
||
|
|
beforeEach(() => Tray.open());
|
||
|
|
|
||
|
|
it("closes the tray", () => {
|
||
|
|
btn.click();
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── init positions wrap at closed (top) ────────────────────────── //
|
||
|
|
|
||
|
|
it("init sets wrap to closed position (top < 0 or = maxTop)", () => {
|
||
|
|
// After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback)
|
||
|
|
// which will be negative. Wrap starts off-screen above.
|
||
|
|
const top = parseInt(wrap.style.top, 10);
|
||
|
|
expect(top).toBeLessThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── resize closes landscape tray ─────────────────────────────── //
|
||
|
|
|
||
|
|
describe("resize closes the tray", () => {
|
||
|
|
it("closes when landscape tray is open", () => {
|
||
|
|
Tray.open();
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .open from btn on resize", () => {
|
||
|
|
Tray.open();
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(btn.classList.contains("open")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("resets wrap to closed top position on resize", () => {
|
||
|
|
Tray.open();
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not re-open a closed tray on resize", () => {
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
// window resize — portrait //
|
||
|
|
// ---------------------------------------------------------------------- //
|
||
|
|
|
||
|
|
describe("window resize (portrait)", () => {
|
||
|
|
it("closes the tray when open", () => {
|
||
|
|
Tray.open();
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removes .open from btn on resize", () => {
|
||
|
|
Tray.open();
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(btn.classList.contains("open")).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("hides the tray panel on resize", () => {
|
||
|
|
Tray.open();
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(tray.style.display).toBe("none");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("resets wrap to closed left position on resize", () => {
|
||
|
|
Tray.open();
|
||
|
|
expect(wrap.style.left).toBe("0px");
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not re-open a closed tray on resize", () => {
|
||
|
|
window.dispatchEvent(new Event("resize"));
|
||
|
|
expect(Tray.isOpen()).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|