70 lines
2.8 KiB
TypeScript
70 lines
2.8 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { Command } from "lucide-react";
|
|
|
|
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import clsx from "clsx";
|
|
import { fieldClass } from "./ui/fieldStyles";
|
|
|
|
// 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"),
|
|
});
|
|
}
|
|
|
|
// Each accessible name CONTAINS what the control shows (WCAG 2.5.3, Label in Name): a voice-control
|
|
// user says what they see — "click Jump to", "click Ctrl K" — and a name that replaced the visible
|
|
// words matched nothing.
|
|
|
|
/** 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 (
|
|
<Pressable
|
|
onClick={openPalette}
|
|
aria-label={`${t("palette.open")} (${cap})`}
|
|
title={`${t("palette.open")} (${cap})`}
|
|
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}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/** The same entry for a page whose header has no search field. */
|
|
export function PaletteLauncher() {
|
|
const { t } = useTranslation();
|
|
const cap = useCap();
|
|
return (
|
|
<Pressable
|
|
onClick={openPalette}
|
|
title={t("palette.open")}
|
|
// The search box's own frame (a 40 glass pill with the field edge): on a page without a search
|
|
// this is where the search would be, and it opens the same place.
|
|
className={clsx(
|
|
fieldClass({ size: "lg", pill: true, floating: true }),
|
|
"pointer-events-auto flex items-center gap-2 text-muted hover:text-fg",
|
|
)}
|
|
>
|
|
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<span>{t("palette.title")}</span>
|
|
<span className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
|
|
{cap}
|
|
</span>
|
|
</Pressable>
|
|
);
|
|
}
|