Raise dialogs and the composer over the mobile drawer
On a phone the folder list is the drawer, so it is also where a new folder is started -- and the New folder dialog was stacked at 900 against the drawer's 950, so it opened behind the folder list with only a sliver showing past the drawer's right edge. Unusable: the name field and the Cancel button were both underneath. The same trigger, the same fault, one layer down: Compose in the drawer opens a full-screen composer, and at 800 that came up behind the drawer too. A modal has to outrank the navigation that raised it. The dialog backdrop goes to 960 and the composer dock to 955, which keeps every relationship those two already had -- a dialog still clears a composer, popovers, tooltips and toasts still clear both -- and adds the one that was missing. Desktop is untouched: the drawer's z-index only exists below 768px, and nothing sat between 800 and 960 anywhere else. The stack is now written down beside `.dialog-backdrop`, and guarded by a test on the stylesheet rather than a component test: jsdom has no paint order, so nothing in a rendered tree can tell that a dialog is behind the drawer that opened it. No user-visible strings change; the nine catalogues are untouched, and the fallback count holds at 16 in each.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The drawer is a phone's only way to a folder, an event, a contact or a new
|
||||
* message -- and each of those answers with a dialog or a full-screen composer
|
||||
* that the drawer used to cover, because both were stacked below it. Nothing
|
||||
* in a component test sees that: jsdom has no paint order, and the store is
|
||||
* perfectly happy while the dialog sits behind the thing that raised it.
|
||||
*
|
||||
* So the guard is on the stylesheet, which is where the bug was.
|
||||
*
|
||||
* Read off disk, not imported: `?raw` comes back empty for a stylesheet under
|
||||
* vitest, which would pass every assertion below on an empty string.
|
||||
*/
|
||||
const here = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url;
|
||||
const css = readFileSync(resolve(dirname(here), "../app.css"), "utf8");
|
||||
|
||||
/** The `z-index` on the last rule for `selector`, which is the one that wins. */
|
||||
function layer(selector: string): number {
|
||||
const rules = [...css.matchAll(new RegExp(`(?:^|[,{}\\s])${selector.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g"))];
|
||||
expect(rules.length, `no rule for ${selector}`).toBeGreaterThan(0);
|
||||
const zs = rules.map((r) => /z-index:\s*(\d+)/.exec(r[1]!)?.[1]).filter(Boolean);
|
||||
expect(zs.length, `no z-index on ${selector}`).toBeGreaterThan(0);
|
||||
return Number(zs[zs.length - 1]);
|
||||
}
|
||||
|
||||
describe("stacking order", () => {
|
||||
it("puts a dialog over the mobile drawer that opened it", () => {
|
||||
expect(layer(".dialog-backdrop")).toBeGreaterThan(layer(".sidebar"));
|
||||
});
|
||||
|
||||
it("puts a full-screen composer over the drawer that opened it", () => {
|
||||
expect(layer(".composer-dock")).toBeGreaterThan(layer(".sidebar"));
|
||||
});
|
||||
|
||||
it("keeps a dialog raised from inside a composer above it", () => {
|
||||
expect(layer(".dialog-backdrop")).toBeGreaterThan(layer(".composer-dock"));
|
||||
});
|
||||
|
||||
it("keeps the drawer above its own backdrop", () => {
|
||||
expect(layer(".sidebar")).toBeGreaterThan(layer(".drawer-backdrop"));
|
||||
});
|
||||
|
||||
it("keeps popovers, tooltips and toasts above dialogs", () => {
|
||||
const dialog = layer(".dialog-backdrop");
|
||||
expect(layer(".popover")).toBeGreaterThan(dialog);
|
||||
expect(layer(".tooltip")).toBeGreaterThan(dialog);
|
||||
expect(layer(".toast-host")).toBeGreaterThan(dialog);
|
||||
});
|
||||
});
|
||||
+26
-2
@@ -1113,7 +1113,30 @@ a.menu-item:hover { color: var(--fg); }
|
||||
:root[data-theme="dark"] .tooltip { background: #e5e9f0; color: #0b1220; }
|
||||
|
||||
/* Dialogs ---------------------------------------------------------------- */
|
||||
.dialog-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); }
|
||||
/*
|
||||
The stacking order everything below is a point on, low to high:
|
||||
|
||||
700 mobile tab bar, FAB
|
||||
940 drawer backdrop
|
||||
950 mobile navigation drawer
|
||||
955 composer dock
|
||||
960 dialog backdrop
|
||||
1000 popover
|
||||
2000 tooltip
|
||||
3000 toast
|
||||
5000 drag ghost
|
||||
|
||||
The drawer is where a phone reaches everything, so it is also where a phone
|
||||
starts a folder, an event, a contact and a message -- and each of those
|
||||
answers with a dialog or a full-screen composer that the drawer then covered,
|
||||
because both were stacked below it. A modal has to outrank the navigation
|
||||
that opened it, or the drawer sits on top of the thing it just asked for and
|
||||
the only way out is a press on the dimmed strip beside it.
|
||||
|
||||
Above the composer, not level with it: a dialog raised from inside a composer
|
||||
-- the discard guard, the attachment picker -- has to clear the composer too.
|
||||
*/
|
||||
.dialog-backdrop { position: fixed; inset: 0; z-index: 960; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.dialog { background: var(--bg-elev); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); width: 100%; max-width: 520px; max-height: calc(100vh - 32px); display: flex; flex-direction: column; animation: rise .18s var(--ease); border: 1px solid var(--border); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@@ -1520,7 +1543,8 @@ a.menu-item:hover { color: var(--fg); }
|
||||
/* ==========================================================================
|
||||
Composer
|
||||
========================================================================== */
|
||||
.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 800; pointer-events: none; }
|
||||
/* Above the mobile drawer, below a dialog -- see the stack by `.dialog-backdrop`. */
|
||||
.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.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); }
|
||||
|
||||
@@ -151,8 +151,10 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
Putting a close where the hamburger was means the second press
|
||||
lands on the control that undoes the first, which is where the hand
|
||||
is already going. It cannot be done by raising the top bar over the
|
||||
drawer instead: it would then also sit over a full-screen composer,
|
||||
which is stacked lower still.
|
||||
drawer instead: the top bar sits under everything that takes the
|
||||
screen -- see the stack by `.dialog-backdrop` -- and lifting it
|
||||
past the drawer would put it in among the composer and the
|
||||
dialogs, which it has no business covering.
|
||||
*/}
|
||||
{isMobile && (
|
||||
<div className="drawer-head">
|
||||
|
||||
Reference in New Issue
Block a user