Paint a full-screen composer above the others
A maximised composer goes position: fixed but stays a child of the dock, and had no z-index of its own. The dock is a stacking context, so the positioned parts of any composer later in the DOM (its recipients row, its editor) painted straight over the full-screen one. Give the maximised composer its own layer, and hide the other composers while one is full screen: they cannot be reached anyway, and the 24px inset would otherwise show their footers along the bottom edge. They stay mounted, so nothing being written in them is lost. Fixes #330
This commit is contained in:
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user