Merge pull request #331 from Coffey-Labs/fix/composer-maximized-stacking

Paint a full-screen composer above the others
This commit is contained in:
jcoffey
2026-09-12 12:24:34 -07:00
committed by GitHub
3 changed files with 86 additions and 2 deletions
+7 -1
View File
@@ -1547,7 +1547,13 @@ a.menu-item:hover { color: var(--fg); }
.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 955; pointer-events: none; } .composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 955; pointer-events: none; }
.composer { pointer-events: auto; width: 580px; max-width: calc(100vw - 32px); height: 600px; max-height: calc(100vh - 24px); display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-3); border: 1px solid var(--border); border-bottom: 0; overflow: hidden; animation: rise .2s var(--ease); } .composer { pointer-events: auto; width: 580px; max-width: calc(100vw - 32px); height: 600px; max-height: calc(100vh - 24px); display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-3); border: 1px solid var(--border); border-bottom: 0; overflow: hidden; animation: rise .2s var(--ease); }
.composer.minimized { height: 44px; width: 280px; } .composer.minimized { height: 44px; width: 280px; }
.composer.maximized { position: fixed; inset: 24px; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); } /* Full screen is still a child of the dock, so without a layer of its own the
positioned parts of any composer after it in the DOM (its recipients row,
its editor) would paint straight over it. The others are hidden while one
is full screen: they cannot be reached anyway, and the strip left under the
24px inset would otherwise show their footers. */
.composer.maximized { position: fixed; inset: 24px; z-index: 1; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); }
.composer-dock.has-maximized > .composer:not(.maximized) { display: none; }
.composer-head { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 6px 0 14px; background: var(--bg-sunken); border-bottom: 1px solid var(--border); flex: 0 0 auto; cursor: default; } .composer-head { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 6px 0 14px; background: var(--bg-sunken); border-bottom: 1px solid var(--border); flex: 0 0 auto; cursor: default; }
.composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; } .composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; }
+3 -1
View File
@@ -9,8 +9,10 @@ export function ComposerDock() {
if (!drafts.length) return null; if (!drafts.length) return null;
// On mobile only the active composer is shown (full screen); others are minimized bars. // On mobile only the active composer is shown (full screen); others are minimized bars.
const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts; const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts;
// On desktop a full-screen composer stands alone: the rest are hidden until it is restored.
const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized);
return ( return (
<div className="composer-dock"> <div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
{visible.map((d) => ( {visible.map((d) => (
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} /> <Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))} ))}
@@ -0,0 +1,76 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCompose, type Draft } from "@/store/compose";
import { ComposerDock } from "../ComposerDock";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// The question is which composers the dock puts on screen, not what is inside
// them, so each composer is a bare marker carrying the state it was given.
vi.mock("../Composer", () => ({
Composer: ({ draft }: { draft: Draft }) => (
<div className={`composer ${draft.maximized ? "maximized" : ""} ${draft.minimized ? "minimized" : ""}`} data-key={draft.key} />
),
}));
/* jsdom has no matchMedia; each test says which side of the 768px breakpoint it stands at. */
function setWidth(px: number) {
window.matchMedia = ((q: string) => ({
matches: /max-width:\s*(\d+)px/.test(q) ? px <= Number(RegExp.$1) : false,
media: q,
addEventListener() {},
removeEventListener() {},
})) as unknown as typeof window.matchMedia;
}
const draft = (key: string, init: Partial<Draft> = {}) => ({ key, minimized: false, maximized: false, ...init }) as Draft;
describe("ComposerDock with a full-screen composer", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
useCompose.setState({ drafts: [], activeKey: null });
});
const render = (drafts: Draft[], activeKey: string) => {
useCompose.setState({ drafts, activeKey });
act(() => root.render(<ComposerDock />));
};
const dock = () => host.querySelector(".composer-dock")!;
it("marks the dock so the other composers are hidden behind it", () => {
setWidth(1300);
render([draft("a"), draft("b", { maximized: true }), draft("c")], "b");
expect(dock().classList.contains("has-maximized")).toBe(true);
// Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost.
expect(host.querySelectorAll(".composer").length).toBe(3);
});
it("leaves the dock alone while nobody is full screen", () => {
setWidth(1300);
render([draft("a"), draft("b")], "b");
expect(dock().classList.contains("has-maximized")).toBe(false);
});
it("does not count a full-screen composer that has since been minimised", () => {
setWidth(1300);
render([draft("a"), draft("b", { maximized: true, minimized: true })], "a");
expect(dock().classList.contains("has-maximized")).toBe(false);
});
it("is not a phone concern: there the active composer is already the only one open", () => {
setWidth(400);
render([draft("a"), draft("b", { maximized: true })], "b");
expect(dock().classList.contains("has-maximized")).toBe(false);
});
});