feat(web): settings rows on one line with icon actions

Each root and policy row is one line: identity left, chips at the right
edge, then pencil/trash icon controls — the episode-row idiom instead of
a two-line group. Every icon action's aria-label names the action and
the row (edit root /mnt/media/tv/main). Delete keeps the arm-then-confirm
behaviour via the shared armedDeleteIcon; the local text-button
armedDelete is gone with it. Under 46rem the row wraps like an episode
row: identity, chips full-width, actions keeping the right edge.

Closes #231

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 11:19:05 +01:00
parent df1af383a3
commit 955a0f309c
2 changed files with 44 additions and 57 deletions
+21 -55
View File
@@ -2,6 +2,7 @@
// plus the settings view over them — same reasoning as search.ts: the // plus the settings view over them — same reasoning as search.ts: the
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it. // generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { armedDeleteIcon, icon } from "./icons";
import { navigate } from "./router"; import { navigate } from "./router";
import type { Root } from "./search"; import type { Root } from "./search";
@@ -185,40 +186,14 @@ function listValue(input: HTMLInputElement): string[] {
.filter((part) => part !== ""); .filter((part) => part !== "");
} }
/** /** Icon-only edit toggle for one row; the label names the row it opens. */
* First click arms the destructive action, second confirms. The armed state function editIcon(label: string): HTMLButtonElement {
* clears on blur or after a few seconds, so an accidental double click const button = el("button", "control control-quiet control-icon");
* never deletes. Shared with the series detail rows — one confirmation
* idiom for row-level destruction, not one per page.
*/
export function armedDelete(label: string, execute: () => void): HTMLButtonElement {
const button = el("button", "control control-quiet readout", label);
button.type = "button"; button.type = "button";
let armed = false; button.setAttribute("aria-label", label);
let resetTimer: number | undefined; button.title = label;
const disarm = () => { button.setAttribute("aria-expanded", "false");
armed = false; button.append(icon("pencil"));
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.textContent = label;
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = `confirm ${label}`;
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button; return button;
} }
@@ -300,31 +275,29 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
/* -- roots -------------------------------------------------------------- */ /* -- roots -------------------------------------------------------------- */
/** A delete/edit action pair sharing the row's feedback line. */ /** The row's line: identity, chips right, the icon action pair, feedback. */
function rowActions( function rowLine(
item: HTMLLIElement, item: HTMLLIElement,
title: string,
chips: HTMLSpanElement,
del: HTMLButtonElement, del: HTMLButtonElement,
edit: HTMLButtonElement, edit: HTMLButtonElement,
): HTMLParagraphElement { ): HTMLParagraphElement {
const actions = el("div", "settings-row-actions"); const actions = el("div", "settings-row-actions");
actions.append(edit, del); actions.append(edit, del);
item.append(actions);
const note = el("p", "add-note readout"); const note = el("p", "add-note readout");
note.setAttribute("role", "status"); note.setAttribute("role", "status");
note.hidden = true; note.hidden = true;
item.append(note); item.append(rowTitle(title), chips, actions, note);
return note; return note;
} }
function rootRow(root: Root): HTMLLIElement { function rootRow(root: Root): HTMLLIElement {
const item = el("li", "queue-item"); const item = el("li", "queue-item settings-row");
const row = el("div", "row");
const chips = el("span", "row-chips"); const chips = el("span", "row-chips");
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name)); chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
row.append(rowTitle(root.path), chips);
item.append(row);
const del = armedDelete("delete", () => { const del = armedDeleteIcon(`delete root ${root.path}`, () => {
void deleteRow("roots", root.id).then((detail) => { void deleteRow("roots", root.id).then((detail) => {
if (detail !== null) { if (detail !== null) {
note.hidden = false; note.hidden = false;
@@ -336,9 +309,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}); });
}); });
const edit = el("button", "control control-quiet readout", "edit"); const edit = editIcon(`edit root ${root.path}`);
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
edit.addEventListener("click", () => { edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel"); const existing = item.querySelector(".edit-panel");
if (existing) { if (existing) {
@@ -350,7 +321,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
edit.setAttribute("aria-expanded", "true"); edit.setAttribute("aria-expanded", "true");
}); });
const note = rowActions(item, del, edit); const note = rowLine(item, root.path, chips, del, edit);
return item; return item;
} }
@@ -432,17 +403,14 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
} }
function policyRow(policy: PolicyDoc): HTMLLIElement { function policyRow(policy: PolicyDoc): HTMLLIElement {
const item = el("li", "queue-item"); const item = el("li", "queue-item settings-row");
const row = el("div", "row");
const chips = el("span", "row-chips"); const chips = el("span", "row-chips");
chips.append(chip(policy.resolution_pref.join(" ") || "no resolutions")); chips.append(chip(policy.resolution_pref.join(" ") || "no resolutions"));
chips.append(chip(requiredAudioLabel(policy))); chips.append(chip(requiredAudioLabel(policy)));
const bandCount = Object.keys(policy.size_bands).length; const bandCount = Object.keys(policy.size_bands).length;
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`)); chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
row.append(rowTitle(policy.name), chips);
item.append(row);
const del = armedDelete("delete", () => { const del = armedDeleteIcon(`delete policy ${policy.name}`, () => {
void deleteRow("policies", policy.id).then((detail) => { void deleteRow("policies", policy.id).then((detail) => {
if (detail !== null) { if (detail !== null) {
note.hidden = false; note.hidden = false;
@@ -454,9 +422,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}); });
}); });
const edit = el("button", "control control-quiet readout", "edit"); const edit = editIcon(`edit policy ${policy.name}`);
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
edit.addEventListener("click", () => { edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel"); const existing = item.querySelector(".edit-panel");
if (existing) { if (existing) {
@@ -468,7 +434,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
edit.setAttribute("aria-expanded", "true"); edit.setAttribute("aria-expanded", "true");
}); });
const note = rowActions(item, del, edit); const note = rowLine(item, policy.name, chips, del, edit);
return item; return item;
} }
+23 -2
View File
@@ -1957,11 +1957,32 @@ body {
/* ---- settings (issue #116) -------------------------------------------- */ /* ---- settings (issue #116) -------------------------------------------- */
/* rows follow the queues pattern: line, then an action pair */ /* one machined line per row (#231): identity, chips at the right edge,
then the icon action pair — the episode-row idiom, not a two-line group */
.settings-row-actions { .settings-row-actions {
display: flex; display: flex;
align-items: center;
gap: var(--space-2); gap: var(--space-2);
margin: var(--space-1) 0 var(--space-2); margin-left: var(--space-3);
}
.settings-row .row-title {
overflow-wrap: anywhere;
}
/* feedback and the edit panel drop below the line, full width */
.settings-row .add-note,
.settings-row .edit-panel {
flex-basis: 100%;
}
/* narrow, the row wraps like an episode row: identity first, chips
full-width (the shared .row-chips rule), the icon pair keeps the right
edge. Lives here, after the base rule, so the margin actually flips. */
@media (max-width: 46rem) {
.settings-row-actions {
margin-left: auto;
}
} }
.form-grid { .form-grid {