fix: admin config pickers fit a phone

A segmented bar cannot shrink or wrap, and the five log levels (~300 px) are wider than a phone leaves beside the navigation rail. A choice is a bar only when its labels are short; the rows stack below sm.
This commit is contained in:
2026-09-17 03:53:40 +02:00
parent f92cd9017f
commit 7789db8240
2 changed files with 30 additions and 17 deletions
@@ -4,7 +4,8 @@
// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
// as the active option rather than no option at all.
//
// i18n is a key-passthrough; the admin config API is stubbed.
// i18n is a key-passthrough, except that an option's label is its value (the bar-or-dropdown choice
// weighs the real label length, which a full key would distort); the admin config API is stubbed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -14,7 +15,10 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
useTranslation: () => ({
t: (key: string) => (key.startsWith("config.choices.") ? key.split(".").pop() : key),
i18n: { language: "en" },
}),
};
});
@@ -48,6 +52,7 @@ vi.mock("../lib/api", async (importOriginal) => {
g: [
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
choice("plex_transcode_preset", PRESETS, "veryfast"),
choice("log_level", ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"], "INFO"),
],
},
secrets_manageable: true,
@@ -79,10 +84,10 @@ describe("ConfigPanel choice keys", () => {
const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
const pressed = () =>
[...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
expect(pressed()).toEqual(["config.choices.plex_transcode_hw.on"]);
expect(pressed()).toEqual(["on"]);
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
fireEvent.click(screen.getByRole("button", { name: "config.choices.plex_transcode_hw.off" }));
fireEvent.click(screen.getByRole("button", { name: "off" }));
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
});
@@ -91,11 +96,7 @@ describe("ConfigPanel choice keys", () => {
mount();
await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
expect(
screen
.getByRole("button", { name: "config.choices.plex_transcode_hw.auto" })
.getAttribute("aria-pressed"),
).toBe("true");
expect(screen.getByRole("button", { name: "auto" }).getAttribute("aria-pressed")).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
});
@@ -103,16 +104,20 @@ describe("ConfigPanel choice keys", () => {
it("renders a long list as a dropdown showing only the value", async () => {
mount();
const trigger = await screen.findByRole("button", {
name: "config.fields.plex_transcode_preset.label: config.choices.plex_transcode_preset.veryfast",
name: "config.fields.plex_transcode_preset.label: veryfast",
});
expect(trigger.textContent).toBe("config.choices.plex_transcode_preset.veryfast");
expect(trigger.textContent).toBe("veryfast");
fireEvent.click(trigger);
// The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
fireEvent.click(
rows.find((r) => r.textContent === "config.choices.plex_transcode_preset.slow")!,
);
fireEvent.click(rows.find((r) => r.textContent === "slow")!);
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
});
it("a few options with long labels are a dropdown too: a bar that wide does not fit a phone", async () => {
mount();
await screen.findByRole("button", { name: "config.fields.log_level.label: INFO" });
expect(screen.queryByRole("group", { name: "config.fields.log_level.label" })).toBeNull();
});
});
+11 -3
View File
@@ -340,7 +340,9 @@ function Field({
const showReset = item.is_set && !isBool;
return (
<div className="flex items-start justify-between gap-3 py-3">
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
// label and hint a sliver of a phone's width.
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
@@ -349,7 +351,7 @@ function Field({
<p className="text-2xs text-muted mt-1">{status}</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
{isBool ? (
<Switch
label={label}
@@ -392,7 +394,12 @@ function Field({
// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
// default on Save) shows the default as the active option, as a text field shows it as placeholder.
//
// "Short" is also about the words, not just their number: a bar cannot shrink or wrap, and five level
// names (TRACE … WARNING, ~300 px) are wider than a phone leaves beside the navigation rail (~250 px).
// Past MAX_SEGMENT_CHARS of labels, in the language shown, it is a dropdown too.
const MAX_SEGMENTS = 6;
const MAX_SEGMENT_CHARS = 22;
function ChoiceField({
item,
@@ -411,7 +418,8 @@ function ChoiceField({
label: t(`config.choices.${item.key}.${c}`, c),
}));
const current = value || String(item.default ?? "");
if (options.length <= MAX_SEGMENTS) {
const labelChars = options.reduce((sum, o) => sum + o.label.length, 0);
if (options.length <= MAX_SEGMENTS && labelChars <= MAX_SEGMENT_CHARS) {
return (
<SegmentedToggle
options={options}