Merge pull request #300 from Coffey-Labs/fix-mobile-dialog-behind-drawer
Raise dialogs and the composer over the mobile drawer
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; }
|
:root[data-theme="dark"] .tooltip { background: #e5e9f0; color: #0b1220; }
|
||||||
|
|
||||||
/* Dialogs ---------------------------------------------------------------- */
|
/* 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; } }
|
@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); }
|
.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; } }
|
@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
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
.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 { 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); }
|
.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
|
Putting a close where the hamburger was means the second press
|
||||||
lands on the control that undoes the first, which is where the hand
|
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
|
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,
|
drawer instead: the top bar sits under everything that takes the
|
||||||
which is stacked lower still.
|
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 && (
|
{isMobile && (
|
||||||
<div className="drawer-head">
|
<div className="drawer-head">
|
||||||
|
|||||||
Reference in New Issue
Block a user