feat(ui): a visible way into the command palette
Ctrl/Cmd+K and the shortcut sheet had no entry point but a palette row, behind the other undiscoverable chord. Owner's pick from the S7 mockups (2026-09-19): a key cap at the end of the header's search field opens the palette, and a page with no search field shows a small "Jump to…" pill with the same key cap in its place, so the entry is on every page. The cap reads the chord the palette row binds from the keymap table (Ctrl K, or ⌘ K on a Mac) and stays out of a phone's header, where there is no keyboard to press it on.
This commit is contained in:
@@ -12,6 +12,7 @@ import { useNavigation } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||
import { useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { PaletteLauncher } from "./PaletteLauncher";
|
||||
|
||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
||||
// has one, and the per-user sync chip at the right end.
|
||||
@@ -42,7 +43,8 @@ const Header = forwardRef<
|
||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
// Per-page SearchBar wiring — null for modules without a search.
|
||||
let search: ReactNode = null;
|
||||
// A page without a search field still gets the palette's entry (PaletteLauncher).
|
||||
let search: ReactNode = <PaletteLauncher />;
|
||||
if (page === "feed") {
|
||||
const trimmed = filters.q.trim();
|
||||
search = (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's
|
||||
// search field, and a "Jump to…" pill in its place on a page that has no search field.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
let page = "home";
|
||||
vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./playlistsContext", () => ({
|
||||
usePlaylists: () => ({ search: "" }),
|
||||
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./channelsContext", () => ({
|
||||
useChannels: () => ({ search: "" }),
|
||||
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./SyncStatus", () => ({ default: () => null }));
|
||||
|
||||
import Header from "./Header";
|
||||
import { closePalette, usePaletteOpen } from "../lib/keymap";
|
||||
import type { Me } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
act(() => closePalette());
|
||||
});
|
||||
|
||||
function mount() {
|
||||
let open = false;
|
||||
function Probe() {
|
||||
open = usePaletteOpen();
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<>
|
||||
<Header me={{ is_demo: false, plex_enabled: false } as Me} onYtSearch={() => {}} />
|
||||
<Probe />
|
||||
</>,
|
||||
);
|
||||
return () => open;
|
||||
}
|
||||
|
||||
describe("the command palette's visible entry", () => {
|
||||
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
|
||||
page = "feed";
|
||||
const isOpen = mount();
|
||||
const cap = screen.getByRole("button", { name: "palette.open" });
|
||||
expect(cap.textContent).toMatch(/^(Ctrl|⌘) K$/);
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
fireEvent.click(cap);
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("is a Jump-to pill on a page with no search field", () => {
|
||||
page = "home";
|
||||
const isOpen = mount();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
const pill = screen.getByRole("button", { name: "palette.open" });
|
||||
expect(pill.textContent).toContain("palette.title");
|
||||
fireEvent.click(pill);
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Command } from "lucide-react";
|
||||
|
||||
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
|
||||
|
||||
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
|
||||
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
|
||||
// behind the other undiscoverable chord. The owner's pick (2026-09-19): a key cap where you already
|
||||
// type, the header's search field; a page with no search field gets the same key cap on a small
|
||||
// "Jump to…" pill in its place, so the entry is on every page.
|
||||
|
||||
/** The chord the palette row actually binds, read from the table rather than restated. */
|
||||
const paletteChord = SHORTCUTS.find((s) => s.id === "palette")?.keys[0] ?? "Mod+k";
|
||||
|
||||
function useCap(): string {
|
||||
const { t } = useTranslation();
|
||||
return formatChord(paletteChord, {
|
||||
Space: t("keys.cap.space"),
|
||||
ContextMenu: t("keys.cap.menu"),
|
||||
});
|
||||
}
|
||||
|
||||
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
|
||||
* too; left out of a phone's narrow header, where there is no keyboard to press it on. */
|
||||
export function PaletteKeycap() {
|
||||
const { t } = useTranslation();
|
||||
const cap = useCap();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPalette}
|
||||
aria-label={t("palette.open")}
|
||||
title={t("palette.open")}
|
||||
className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
|
||||
>
|
||||
{cap}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same entry for a page whose header has no search field. */
|
||||
export function PaletteLauncher() {
|
||||
const { t } = useTranslation();
|
||||
const cap = useCap();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPalette}
|
||||
aria-label={t("palette.open")}
|
||||
className="pointer-events-auto glass-menu flex h-11 items-center gap-2 rounded-2xl px-3 text-sm text-muted transition hover:text-fg"
|
||||
>
|
||||
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t("palette.title")}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden"
|
||||
>
|
||||
{cap}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useSearchFocusKey } from "../lib/keymap";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { PaletteKeycap } from "./PaletteLauncher";
|
||||
|
||||
// The floating search pill in the top header. One component for every module that searches
|
||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||
@@ -100,6 +101,7 @@ export default function SearchBar({
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<PaletteKeycap />
|
||||
{onGo && (
|
||||
<FocusSafeButton
|
||||
onClick={fire}
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
},
|
||||
"actions": {
|
||||
"surprise": "Surprise me"
|
||||
}
|
||||
},
|
||||
"open": "Open the command palette"
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
},
|
||||
"actions": {
|
||||
"surprise": "Meglepetés"
|
||||
}
|
||||
},
|
||||
"open": "Parancspaletta megnyitása"
|
||||
}
|
||||
|
||||
@@ -443,6 +443,8 @@ export const useNavigablePages = (): readonly Page[] => navigablePages.use();
|
||||
const paletteOpen = createStore(false);
|
||||
|
||||
export const closePalette = (): void => paletteOpen.set(false);
|
||||
/** Open it from a visible control (the header's key cap / launcher), not only from Ctrl/Cmd+K. */
|
||||
export const openPalette = (): void => paletteOpen.set(true);
|
||||
export const usePaletteOpen = (): boolean => paletteOpen.use();
|
||||
|
||||
/** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside
|
||||
|
||||
Reference in New Issue
Block a user