feat(e10): a jump-to palette on Ctrl/Cmd+K
Reaching a channel three modules away cost a rail, a page and a scroll. Now it costs a keystroke and half a word: Mod+K searches modules, channels, playlists, the saved views applicable where you stand, and the app-wide actions. Scope agreed with the product owner: navigation + actions, but only the reversible local ones (the shortcuts sheet, the theme). "Sync from YouTube" is deliberately absent — it is a long-running job against a quota, it wants the progress and error surface the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it would have saved. Matching is word-wise and accent-folded, because this is a bilingual app and nobody reaches for the accent keys in a hurry: "pod thevr" finds TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat result list so crossing a group heading is invisible, and the field carries the combobox/listbox aria tree that virtual selection needs. ui/CommandPalette is domain-free (items are a prop, per the ui/ import rule); AppCommandPalette supplies them. The shell is Modal again, with a new `top` placement so a search-first dialog grows downwards instead of jumping as results change. Also fixes a bug the palette hit on its first run, and that PlayerModal had already hit once: closing a dialog reclaims its Back-to-close history entry with a programmatic history.back(), which undoes any pushState made before it lands — so a command that navigates only closed the palette. The rule is now closeThenNavigate() in history.ts with a test, and PlayerModal's inline copy is gone. The global `h` moves out of KeyboardHelp into useGlobalKeymap beside Mod+K, so one listener owns the whole `global` scope and dispatch's exhaustiveness keeps meaning what it says.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the jump-to palette: filtering (word-wise and accent-insensitive), arrow/Enter
|
||||
// selection over the FLAT result list, and closing before the command runs. Items are plain props
|
||||
// (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough.
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import CommandPalette, { type CommandGroup } from "./CommandPalette";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom has no layout, so scrollIntoView is missing on the elements the active-row effect touches.
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before
|
||||
// running the command (see closeThenNavigate), so the command lands a tick later — hence the waits.
|
||||
const ran: string[] = [];
|
||||
const groups = (): CommandGroup[] => [
|
||||
{
|
||||
id: "modules",
|
||||
label: "Modules",
|
||||
items: [
|
||||
{ id: "page:feed", label: "Feed", run: () => ran.push("page:feed") },
|
||||
{ id: "page:plex", label: "Plex", keywords: "media library", run: () => ran.push("plex") },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
items: [
|
||||
{ id: "ch:1", label: "TheVR Podcast", hint: "@thevr", run: () => ran.push("ch:1") },
|
||||
{ id: "ch:2", label: "Pénzügyi Fitnesz", run: () => ran.push("ch:2") },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// The palette must REALLY close, not just report it: `choose` waits for the Back-to-close entry
|
||||
// that only a genuine unmount reclaims, so a harness that keeps it mounted would never run anything.
|
||||
function Harness() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
return (
|
||||
<CommandPalette
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
ran.push("closed");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
groups={groups()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
ran.length = 0;
|
||||
return render(<Harness />);
|
||||
};
|
||||
|
||||
const rows = () =>
|
||||
[...document.querySelectorAll("[data-cmd-id]")].map((el) => el.getAttribute("data-cmd-id"));
|
||||
|
||||
/** The virtually-selected row, read the way a screen reader would. */
|
||||
const selected = () =>
|
||||
document.querySelector("[aria-selected='true']")?.getAttribute("data-cmd-id") ?? null;
|
||||
|
||||
const field = () => screen.getByRole("combobox", { name: "palette.placeholder" });
|
||||
const type = (value: string) => fireEvent.change(field(), { target: { value } });
|
||||
const press = (key: string) => fireEvent.keyDown(field(), { key });
|
||||
|
||||
describe("CommandPalette", () => {
|
||||
it("renders nothing while closed", () => {
|
||||
render(<CommandPalette open={false} onClose={() => {}} groups={groups()} />);
|
||||
expect(screen.queryByText("palette.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists every group until you type", () => {
|
||||
open();
|
||||
expect(rows()).toEqual(["page:feed", "page:plex", "ch:1", "ch:2"]);
|
||||
});
|
||||
|
||||
// You half-remember a name; you should not have to remember the word order or the spacing.
|
||||
it("matches on each word, in any order", () => {
|
||||
open();
|
||||
type("pod thevr");
|
||||
expect(rows()).toEqual(["ch:1"]);
|
||||
});
|
||||
|
||||
// Bilingual app, hurried typing: nobody reaches for the accent keys.
|
||||
it("matches without accents", () => {
|
||||
open();
|
||||
type("penzugyi");
|
||||
expect(rows()).toEqual(["ch:2"]);
|
||||
});
|
||||
|
||||
it("matches hidden keywords and the group name", () => {
|
||||
open();
|
||||
type("media");
|
||||
expect(rows()).toEqual(["page:plex"]);
|
||||
type("channels");
|
||||
expect(rows()).toEqual(["ch:1", "ch:2"]);
|
||||
});
|
||||
|
||||
it("says so when nothing matches", () => {
|
||||
open();
|
||||
type("zzzz");
|
||||
expect(rows()).toEqual([]);
|
||||
expect(screen.getByText("palette.empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("selects the first row and points the input at it", () => {
|
||||
open();
|
||||
expect(selected()).toBe("page:feed");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("combobox", { name: "palette.placeholder" })
|
||||
.getAttribute("aria-activedescendant"),
|
||||
).toContain("page:feed");
|
||||
});
|
||||
|
||||
// The arrows walk the flat result list, not the sections — crossing a group heading is invisible.
|
||||
it("walks results with the arrows, across group boundaries", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("ArrowDown");
|
||||
expect(selected()).toBe("ch:1");
|
||||
});
|
||||
|
||||
it("wraps in both directions", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(selected()).toBe("ch:2");
|
||||
press("ArrowDown");
|
||||
expect(selected()).toBe("page:feed");
|
||||
});
|
||||
|
||||
it("keeps the selection valid when the filter shrinks the list", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("ArrowDown");
|
||||
press("ArrowDown"); // ch:2
|
||||
type("thevr"); // one row left; the old index is out of range
|
||||
expect(selected()).toBe("ch:1");
|
||||
});
|
||||
|
||||
// The ORDER is the point (closeThenNavigate): the dialog closes and gives its Back-to-close
|
||||
// history entry back BEFORE the command runs, because a command that navigates would otherwise
|
||||
// have its pushState undone by that reclaim.
|
||||
it("closes, and runs the command only once the close has landed", async () => {
|
||||
open();
|
||||
fireEvent.click(screen.getByText("TheVR Podcast"));
|
||||
expect(ran).toEqual(["closed"]);
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]));
|
||||
});
|
||||
|
||||
// Only ONE test here can go through the full close→popstate→run cycle: jsdom gives a file a single
|
||||
// session-history entry, so the first back() consumes it and later ones fire no popstate. Which
|
||||
// command Enter picks is covered by the selection assertions above; this checks it chooses at all.
|
||||
it("chooses the selected row on Enter", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
expect(ran).toEqual(["closed"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { closeThenNavigate } from "../../lib/history";
|
||||
|
||||
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
||||
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
|
||||
// modules away should cost a keystroke and a word, not a rail, a page and a scroll.
|
||||
//
|
||||
// Domain-free on purpose: the items arrive as a prop, because `components/ui` may not import a
|
||||
// module or an API slice. `AppCommandPalette` (one level up) is what knows about modules, channels
|
||||
// and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape,
|
||||
// focus restore and Back-to-close as every other dialog.
|
||||
|
||||
export interface CommandItem {
|
||||
/** Unique across the whole palette; also the React key. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Right-aligned secondary text — usually the group, so a result stays legible once filtered. */
|
||||
hint?: string;
|
||||
icon?: ReactNode;
|
||||
/** Extra text the filter matches but the row does not show (a channel handle, an alias). */
|
||||
keywords?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export interface CommandGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
items: CommandItem[];
|
||||
}
|
||||
|
||||
/** Case- and accent-insensitive, so "audio" finds "Audió" and "penzugy" finds "pénzügy" — this is a
|
||||
* bilingual app and nobody types the accents when they are in a hurry. */
|
||||
function fold(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "");
|
||||
}
|
||||
|
||||
/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so
|
||||
* "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */
|
||||
function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean {
|
||||
if (words.length === 0) return true;
|
||||
const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
|
||||
export default function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
groups,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
groups: CommandGroup[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const listId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
// Ref on the highlighted row rather than a selector lookup: item ids come from domain data
|
||||
// (playlist names, channel handles) and would need escaping to be safe in a query string.
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
|
||||
// query from last time hides everything you might want now.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const words = fold(query.trim()).split(/\s+/).filter(Boolean);
|
||||
return groups
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [groups, query]);
|
||||
|
||||
// One flat list behind the grouped rendering — arrow keys walk results, not sections.
|
||||
const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]);
|
||||
const activeId = flat[Math.min(active, flat.length - 1)]?.id;
|
||||
|
||||
// Keep the highlighted row in view when the arrows walk past the fold.
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands
|
||||
// navigate, and a pushState issued before that reclaim is undone by it — see closeThenNavigate.
|
||||
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run);
|
||||
|
||||
// These live on the input rather than in the shared keymap: they are the field's own editing keys
|
||||
// (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
|
||||
const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (flat.length === 0) return;
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((i) => (Math.min(i, flat.length - 1) + step + flat.length) % flat.length);
|
||||
} else if (e.key === "Enter") {
|
||||
const item = flat[Math.min(active, flat.length - 1)];
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
choose(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2">
|
||||
<Search className="w-4 h-4 shrink-0 text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onInputKey}
|
||||
placeholder={t("palette.placeholder")}
|
||||
aria-label={t("palette.placeholder")}
|
||||
// A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in
|
||||
// the field, so a screen reader has to be told which row is current.
|
||||
role="combobox"
|
||||
aria-expanded
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
|
||||
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id={listId} role="listbox" className="mt-3 max-h-[50vh] overflow-y-auto">
|
||||
{flat.length === 0 ? (
|
||||
<p className="px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
|
||||
) : (
|
||||
visible.map((g) => (
|
||||
<div key={g.id} className="mb-2 last:mb-0">
|
||||
<div
|
||||
role="presentation"
|
||||
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`${listId}-${item.id}`}
|
||||
ref={item.id === activeId ? activeRef : undefined}
|
||||
data-cmd-id={item.id}
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
type="button"
|
||||
onClick={() => choose(item)}
|
||||
onMouseMove={() => setActive(flat.indexOf(item))}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
|
||||
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,12 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import KeyboardHelp from "./KeyboardHelp";
|
||||
import { closeKeyboardHelp, useHelpConditions, useKeyScope } from "../../lib/keymap";
|
||||
import {
|
||||
closeKeyboardHelp,
|
||||
useGlobalKeymap,
|
||||
useHelpConditions,
|
||||
useKeyScope,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -20,6 +25,17 @@ afterEach(() => {
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
/** App's job: it binds the `global` scope (h / Mod+K) once for the whole tree. */
|
||||
function Host({ children }: { children?: React.ReactNode }) {
|
||||
useGlobalKeymap();
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<KeyboardHelp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stand-in for a surface that registers a scope while mounted, the way the players do. */
|
||||
function Surface({ episode }: { episode?: boolean }) {
|
||||
useKeyScope("plexPlayer");
|
||||
@@ -36,7 +52,7 @@ const descriptions = () => screen.getAllByRole("definition").map((el) => el.text
|
||||
|
||||
describe("KeyboardHelp", () => {
|
||||
it("opens and closes on the global h", () => {
|
||||
render(<KeyboardHelp />);
|
||||
render(<Host />);
|
||||
expect(screen.queryByText("keys.title")).toBeNull();
|
||||
pressH();
|
||||
expect(screen.getByText("keys.title")).toBeTruthy();
|
||||
@@ -45,7 +61,7 @@ describe("KeyboardHelp", () => {
|
||||
});
|
||||
|
||||
it("does not open while a field has focus", () => {
|
||||
render(<KeyboardHelp />);
|
||||
render(<Host />);
|
||||
const input = document.createElement("input");
|
||||
document.body.append(input);
|
||||
act(() => {
|
||||
@@ -57,31 +73,29 @@ describe("KeyboardHelp", () => {
|
||||
|
||||
it("leads with the live surface and trails with the app-wide keys", () => {
|
||||
render(
|
||||
<>
|
||||
<Host>
|
||||
<Surface />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
</Host>,
|
||||
);
|
||||
pressH();
|
||||
const rows = descriptions();
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||
expect(rows[0]).toBe("keys.playPause");
|
||||
expect(rows.slice(-2)).toEqual(["keys.help", "keys.contextMenu"]);
|
||||
expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]);
|
||||
});
|
||||
|
||||
it("lists only the app-wide keys when no surface is registered", () => {
|
||||
render(<KeyboardHelp />);
|
||||
render(<Host />);
|
||||
pressH();
|
||||
expect(descriptions()).toEqual(["keys.help", "keys.contextMenu"]);
|
||||
expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]);
|
||||
});
|
||||
|
||||
// "Previous / next episode" inside a movie would be a promise the player does not keep.
|
||||
it("hides a conditional row until its surface says it applies", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<Host>
|
||||
<Surface />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
</Host>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).not.toContain("keys.episode");
|
||||
@@ -89,10 +103,9 @@ describe("KeyboardHelp", () => {
|
||||
closeKeyboardHelp(); // the sheet outlives the tree it was opened from
|
||||
|
||||
render(
|
||||
<>
|
||||
<Host>
|
||||
<Surface episode />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
</Host>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.episode");
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./Modal";
|
||||
import {
|
||||
closeKeyboardHelp,
|
||||
dispatch,
|
||||
formatChord,
|
||||
helpLines,
|
||||
toggleKeyboardHelp,
|
||||
useActiveScopes,
|
||||
useHelpConditionValues,
|
||||
useKeyboardHelpOpen,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App.
|
||||
// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in
|
||||
// `useGlobalKeymap`, beside the palette's Mod+K, so one listener owns the whole `global` scope.
|
||||
//
|
||||
// It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex
|
||||
// player's keys while you are in the player, the app-wide ones underneath — so there is a single
|
||||
@@ -31,19 +30,6 @@ export default function KeyboardHelp() {
|
||||
const scopes = useActiveScopes();
|
||||
const conditions = useHelpConditionValues();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "global", {
|
||||
help: (ev) => {
|
||||
ev.preventDefault();
|
||||
toggleKeyboardHelp();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
||||
// a promise the player does not keep.
|
||||
|
||||
@@ -32,11 +32,13 @@ export default function Modal({
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
|
||||
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
|
||||
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
|
||||
* two shells drift apart. */
|
||||
placement?: "center" | "drawer";
|
||||
/** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top
|
||||
* of the viewport (where a search-first dialog like the command palette belongs, so the list grows
|
||||
* downwards instead of the whole thing jumping as results change). Everything else about the shell
|
||||
* — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A
|
||||
* placement is not a second dialog implementation: duplicating that behaviour is exactly how two
|
||||
* shells drift apart. */
|
||||
placement?: "center" | "drawer" | "top";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
@@ -114,10 +116,15 @@ export default function Modal({
|
||||
}, [restoreTo]);
|
||||
|
||||
const drawer = placement === "drawer";
|
||||
const top = placement === "top";
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
||||
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
|
||||
drawer
|
||||
? "justify-end"
|
||||
: top
|
||||
? "items-start justify-center p-4 pt-[8vh] sm:p-6 sm:pt-[10vh]"
|
||||
: "items-center justify-center p-4 sm:p-6"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
|
||||
Reference in New Issue
Block a user