Unify selectable navigation rows

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 11:09:56 +02:00
parent 80cf879e0f
commit 3600525775
6 changed files with 117 additions and 15 deletions
+10 -3
View File
@@ -1,6 +1,7 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Project } from "../api";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@customElement("project-list")
@@ -35,10 +36,16 @@ export class ProjectList extends LitElement {
<section>
<h2>Projects</h2>
${this.projects.map((project) => html`
<div class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}>
<button class="action-main" @click=${() => this.onSelect?.(project)}>
<div
class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}
tabindex="0"
title=${project.path}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
>
<div class="action-main">
<span>${project.name}</span><small>${project.path}</small>
</button>
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
${this.openMenuProjectId === project.id ? html`
+11 -3
View File
@@ -1,6 +1,7 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
function sessionLabel(session: SessionInfo): string {
@@ -69,10 +70,17 @@ export class SessionList extends LitElement {
const { session } = row;
const cappedDepth = Math.min(row.depth, 2);
return html`
<div class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}" style=${`--depth:${String(cappedDepth)}`}>
<button class="action-main" @click=${() => this.onSelect?.(session)}>
<div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}"
style=${`--depth:${String(cappedDepth)}`}
tabindex="0"
title=${session.path}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(session)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(session)); }}
>
<div class="action-main">
<span>${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button>
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
${this.openMenuSessionId === session.id ? html`
+10 -3
View File
@@ -2,6 +2,7 @@ import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Workspace } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import { renderWorkspaceLabelItems } from "./workspaceLabel";
@@ -19,10 +20,16 @@ export class WorkspaceList extends LitElement {
${this.workspaces.map((workspace) => {
const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`;
return html`
<div class=${this.selected?.id === workspace.id ? "workspace-row selected" : "workspace-row"}>
<div class="workspace-main">
<div
class=${`action-row workspace-row ${this.selected?.id === workspace.id ? "selected" : ""}`}
tabindex="0"
title=${workspace.path}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(workspace)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace)); }}
>
<div class="action-main">
<span class="workspace-label">
<button class="workspace-select" title=${workspace.path} @click=${() => this.onSelect?.(workspace)}>${label}</button>
<span class="workspace-label-base">${label}</span>
${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))}
</span>
<small>${workspace.path}</small>
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
describe("selectable row activation", () => {
it("activates rows from non-interactive click targets", () => {
const action = vi.fn();
activateSelectableRow(eventWithPath({ matches: () => false }), action);
expect(action).toHaveBeenCalledOnce();
});
it("preserves contributed links and other interactive elements", () => {
const action = vi.fn();
activateSelectableRow(eventWithPath({ matches: (selector: string) => selector.includes("a[href]") }), action);
expect(action).not.toHaveBeenCalled();
});
it("activates rows from Enter and Space", () => {
const enterAction = vi.fn();
const spaceAction = vi.fn();
const enter = keyboardEventWithPath("Enter", { matches: () => false });
const space = keyboardEventWithPath(" ", { matches: () => false });
activateSelectableRowFromKeyboard(enter, enterAction);
activateSelectableRowFromKeyboard(space, spaceAction);
expect(enterAction).toHaveBeenCalledOnce();
expect(spaceAction).toHaveBeenCalledOnce();
expect(enter.preventDefault).toHaveBeenCalledOnce();
expect(space.preventDefault).toHaveBeenCalledOnce();
});
it("does not activate rows from keyboard events inside interactive elements", () => {
const action = vi.fn();
const event = keyboardEventWithPath("Enter", { matches: (selector: string) => selector.includes("button") });
activateSelectableRowFromKeyboard(event, action);
expect(action).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
function eventWithPath(target: Pick<Element, "matches">): MouseEvent {
return { composedPath: () => [target] } as unknown as MouseEvent;
}
function keyboardEventWithPath(key: string, target: Pick<Element, "matches">): KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> } {
return { key, preventDefault: vi.fn(), composedPath: () => [target] } as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> };
}
@@ -0,0 +1,32 @@
const interactiveSelector = [
"a[href]",
"button",
"input",
"select",
"textarea",
"summary",
"[role='button']",
"[role='link']",
"[contenteditable='true']",
].join(",");
export function isFromInteractiveElement(event: Event): boolean {
return event.composedPath().some((target) => isElementLike(target) && target.matches(interactiveSelector));
}
function isElementLike(target: EventTarget): target is Element {
if (typeof Element !== "undefined") return target instanceof Element;
return typeof (target as Partial<Element>).matches === "function";
}
export function activateSelectableRow(event: MouseEvent, action: () => void): void {
if (isFromInteractiveElement(event)) return;
action();
}
export function activateSelectableRowFromKeyboard(event: KeyboardEvent, action: () => void): void {
if (event.key !== "Enter" && event.key !== " ") return;
if (isFromInteractiveElement(event)) return;
event.preventDefault();
action();
}
+5 -6
View File
@@ -109,17 +109,16 @@ export const listStyles = css`
h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.workspace-row { margin: 6px 0; }
.workspace-row.selected .workspace-main { border-color: #58a6ff; background: #0d2847; }
.workspace-main { box-sizing: border-box; display: block; width: 100%; border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 7px 9px; text-align: left; }
.workspace-select { min-width: 0; border: 0; background: transparent; color: #e6edf3; padding: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; }
.subheading { margin-top: 14px; }
.section-toggle { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; }
.section-toggle small { display: inline; color: inherit; font-size: inherit; }
.action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; }
.action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; cursor: pointer; }
.action-row:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 8px; }
.action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: #58a6ff; background: #0d2847; }
.action-row.archived .action-main { color: #8b949e; }
.action-main { min-width: 0; text-align: left; border-top-right-radius: 0; border-bottom-right-radius: 0; padding-left: calc(9px + var(--depth, 0) * 16px); }
.action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid #30363d; border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; }
.action-row:not(.selected):hover .action-main { background: #21262d; }
.workspace-row .action-main { border-radius: 8px; }
.tree-marker { color: #6e7681; margin-right: 5px; }
.badge { display: inline-block; margin-left: 5px; border: 1px solid #30363d; border-radius: 999px; color: #8b949e; padding: 0 5px; font-size: 11px; font-weight: 400; }
.action-menu { position: relative; align-self: stretch; }