fix(ui): the header search has room on a tablet

On touch the 44 px key cap and Go filled a 44 px pill edge to edge and the
placeholder was cut. The key cap is left out on touch (⌘K still works), the
pill grows to 52 px so a ring shows round Go, the box's floor counts the
controls beside the text, and Go drops to its icon by the column's width,
not the screen's.
This commit is contained in:
2026-10-08 02:52:32 +02:00
parent 5249c41edc
commit 45a9ceae25
3 changed files with 41 additions and 10 deletions
+3 -2
View File
@@ -28,7 +28,8 @@ function useCap(): string {
// 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. */
* too; left out of a phone's narrow header and of every touch screen, where there is (usually) no
* keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */
export function PaletteKeycap() {
const { t } = useTranslation();
const cap = useCap();
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
title={`${t("palette.open")} (${cap})`}
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden"
>
{cap}
</Pressable>
+28 -3
View File
@@ -4,8 +4,8 @@
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import SearchBar from "./SearchBar";
vi.mock("react-i18next", async (importOriginal) => {
@@ -13,14 +13,39 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(cleanup);
describe("SearchBar responsive wrapper", () => {
it("carries the container-query classes that make it wrap instead of collapsing", () => {
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
const cls = (container.firstElementChild as HTMLElement).className;
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
expect(cls).toContain("@min-[420px]/appmain:min-w-[18rem]");
// On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
expect(cls).toContain("@max-[640px]/appmain:basis-full");
expect(cls).toContain("@max-[640px]/appmain:order-last");
});
});
// On a tablet the 44 px key cap and Go sat on the edge of a 44 px pill and crowded the text out.
describe("SearchBar on a touch screen", () => {
it("drops the key cap, and grows the pill past the 44 px Go so a ring shows round it", () => {
const { container, getByRole } = render(
<SearchBar value="" onChange={() => {}} placeholder="Search…" onGo={() => {}} />,
);
expect(getByRole("button", { name: /palette.open/ }).className).toContain(
"pointer-coarse:hidden",
);
const pill = container.querySelector("input")!.closest<HTMLElement>("[class*='h-10']")!;
expect(pill.className).toContain("pointer-coarse:h-13");
});
it("shortens Go to its icon by the column's width, not the screen's", () => {
const { getByText } = render(
<SearchBar value="" onChange={() => {}} onGo={() => {}} goIcon={<svg />} goLabel="Go far" />,
);
const label = getByText("Go far");
expect(label.className).toContain("@min-[900px]/appmain:inline");
expect(label.className).not.toMatch(/(^|\s)md:inline/);
});
});
+10 -5
View File
@@ -72,14 +72,18 @@ export default function SearchBar({
// overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
// the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
// a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it. The floor
// is the input's room plus what sits beside it (the icon, the key cap, Go): at 15rem the box kept
// only ~8rem for the text and a tablet cut the placeholder ("Search your subscriptior").
return (
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[18rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12.
On touch the pill grows past the 44 px Go so a ring of the pill shows round it — a 44 Go in a
44 pill sat on its edge. */}
<div
className={clsx(
fieldClass({ size: "free", pill: true, floating: true }),
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:text-base",
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:h-13 pointer-coarse:pr-1 pointer-coarse:text-base",
)}
>
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
@@ -116,7 +120,8 @@ export default function SearchBar({
title={goTitle ?? goLabel ?? t("header.goTip")}
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{/* By the column, not the screen: a tablet's 768–1024 px window has a tight column. */}
<span className={goIcon ? "hidden @min-[900px]/appmain:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</Button>