From 8dc4976c2c250e74dc93062fc7270246f9164e02 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 03:29:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(channels):=20horizontal=20=E2=88=92/?= =?UTF-8?q?=EF=BC=8B=20priority=20stepper=20with=20pop-free=20inline=20edi?= =?UTF-8?q?t=20(S5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the shared PriorityStepper (channel manager cell + detail header) from a value-plus-stacked-arrows control into a horizontal [−] value [+] segmented stepper. The value now sits centred between the buttons and opens an inline editor in a FIXED-width slot, so click-to-type no longer "pops" the control wider (the old absolutely-positioned overlay grew w-6 → w-14). One `size` prop drives the two surfaces: `sm` draws its own bordered pill for the standalone table cell; `md` drops the outer border and keeps only the segment dividers, blending into the detail header's lozenge instead of double-bordering it. Hold-to-repeat, negatives, and the debounced-flush persistence hook are unchanged. The manager priority column widens 3.5rem → 5.5rem to fit the horizontal control under fixed table layout. Tests: 7 new DOM tests for the stepper interaction contract. --- frontend/src/components/ChannelPage.tsx | 1 + .../components/PriorityStepper.dom.test.tsx | 81 +++++++++++++++ frontend/src/components/PriorityStepper.tsx | 99 ++++++++++++------- frontend/src/components/channelColumns.tsx | 2 +- 4 files changed, 146 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/PriorityStepper.dom.test.tsx diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index b23e49af..0595c20b 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -418,6 +418,7 @@ export default function ChannelPage({ priority={ch.priority} onPriority={(d) => bumpPriority(channelId, d)} onSet={(v) => setPriority(channelId, v)} + size="md" /> )} diff --git a/frontend/src/components/PriorityStepper.dom.test.tsx b/frontend/src/components/PriorityStepper.dom.test.tsx new file mode 100644 index 00000000..6e8e6f4f --- /dev/null +++ b/frontend/src/components/PriorityStepper.dom.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// +// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is +// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual +// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { PriorityStepper } from "./PriorityStepper"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +afterEach(cleanup); + +const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" }); +const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" }); +const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" }); + +describe("PriorityStepper", () => { + it("shows the current priority and both step buttons", () => { + render(); + expect(valueBtn().textContent).toBe("12"); + expect(raise()).toBeTruthy(); + expect(lower()).toBeTruthy(); + }); + + it("tapping + / − nudges by ±1", () => { + const onPriority = vi.fn(); + render(); + fireEvent.click(raise()); + fireEvent.click(lower()); + expect(onPriority.mock.calls).toEqual([[1], [-1]]); + }); + + it("clicking the value opens an inline editor seeded with the current value", () => { + render(); + fireEvent.click(valueBtn()); + const input = screen.getByRole("spinbutton") as HTMLInputElement; + expect(input.value).toBe("7"); + }); + + it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => { + const onSet = vi.fn(); + render(); + fireEvent.click(valueBtn()); + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "-3" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onSet).toHaveBeenCalledWith(-3); + }); + + it("Escape cancels the edit without committing", () => { + const onSet = vi.fn(); + render(); + fireEvent.click(valueBtn()); + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "99" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onSet).not.toHaveBeenCalled(); + // editor closed → the value button is back + expect(valueBtn().textContent).toBe("7"); + }); + + it("committing an unchanged value does not call onSet", () => { + const onSet = vi.fn(); + render(); + fireEvent.click(valueBtn()); + fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" }); + expect(onSet).not.toHaveBeenCalled(); + }); + + it("renders the same interactive contract at size=md", () => { + const onPriority = vi.fn(); + render(); + expect(valueBtn().textContent).toBe("3"); + fireEvent.click(raise()); + expect(onPriority).toHaveBeenCalledWith(1); + }); +}); diff --git a/frontend/src/components/PriorityStepper.tsx b/frontend/src/components/PriorityStepper.tsx index 24b83c70..5f92a036 100644 --- a/frontend/src/components/PriorityStepper.tsx +++ b/frontend/src/components/PriorityStepper.tsx @@ -1,19 +1,28 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowDown, ArrowUp } from "lucide-react"; +import { Minus, Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import Tooltip from "./Tooltip"; +type Size = "sm" | "md"; + +const SIZED = { + sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" }, + md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" }, +} as const; + function PriorityStepButton({ delta, Icon, label, + sizeCls, onHoldStart, onStop, onTap, }: { delta: number; - Icon: typeof ArrowUp; + Icon: typeof Plus; label: string; + sizeCls: { btn: string; icon: string }; onHoldStart: (delta: number) => void; onStop: () => void; onTap: (delta: number) => void; @@ -32,28 +41,35 @@ function PriorityStepButton({ onPointerUp={onStop} onPointerCancel={onStop} onClick={() => onTap(delta)} - className="text-muted hover:text-accent leading-none" + className={`inline-flex items-center justify-center text-muted hover:text-accent ${sizeCls.btn}`} aria-label={label} > - + ); } /** - * A press-and-hold-repeat priority control with an inline-editable value (negatives allowed). - * Shared by the channel manager table (a cell) and the channel detail page (a header control), so - * the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold - * auto-repeat), `onSet(value)` writes a typed-in absolute value. + * A horizontal −/+ priority stepper with the value centred between the buttons and click-to-type + * inline editing (negatives allowed). Shared by the channel manager table (a cell, `size="sm"`) and + * the channel detail page (a header control, `size="md"`), so the two never drift. The caller owns + * persistence: `onPriority(delta)` nudges by ±1 (with hold auto-repeat), `onSet(value)` writes a + * typed-in absolute value. + * + * The `size="sm"` variant draws its own bordered pill (it stands alone in a table cell); `size="md"` + * drops the outer border and keeps only the segment dividers, so it blends into the detail header's + * surrounding lozenge instead of double-bordering it. */ export function PriorityStepper({ priority, onPriority, onSet, + size = "sm", }: { priority: number; onPriority: (delta: number) => void; onSet: (value: number) => void; + size?: Size; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); @@ -61,6 +77,7 @@ export function PriorityStepper({ const holdDelay = useRef | null>(null); const holdRepeat = useRef | null>(null); const suppressClick = useRef(false); + const cls = SIZED[size]; const stop = useCallback(() => { if (holdDelay.current) clearTimeout(holdDelay.current); @@ -103,21 +120,40 @@ export function PriorityStepper({ setEditing(true); }; - // The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the - // arrows while you press-and-hold — never covers the live value you're setting. Clicking the value - // opens an inline edit to type any integer directly (negatives allowed). + // Segmented control: [−] value [+]. The value slot keeps a FIXED width (`cls.value`) whether it's + // showing the number or the edit input, so opening the editor never resizes the control or shifts + // the buttons — the old absolutely-positioned overlay "popped" wider, this doesn't. While editing + // the whole pill borders in `accent` to read as focused. + const framed = + size === "sm" + ? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}` + : ""; return ( -
- {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely - positioned OVER it (out of flow) so opening the editor never changes the row height/width — - it just overlays, extending left for a longer number. */} - +
+ + @@ -134,28 +170,19 @@ export function PriorityStepper({ else if (e.key === "Escape") setEditing(false); }} aria-label={t("channels.row.priorityHint")} - className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" /> )} - - - - +
); diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index f5104fdd..bf9082c7 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -198,7 +198,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.priority,