Files
inbuxa-webmail/web/src/styles/__tests__/stacking-order.test.ts
T
jcoffey-dev cd6dff5346 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.
2026-09-06 21:39:45 -07:00

54 lines
2.3 KiB
TypeScript

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);
});
});