From 36005257757c81cf75b0243cdd57a9b10d20f478 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 11:09:56 +0200 Subject: [PATCH] Unify selectable navigation rows --- src/client/src/components/ProjectList.ts | 13 +++-- src/client/src/components/SessionList.ts | 14 ++++-- src/client/src/components/WorkspaceList.ts | 13 +++-- .../src/components/selectableRow.test.ts | 49 +++++++++++++++++++ src/client/src/components/selectableRow.ts | 32 ++++++++++++ src/client/src/components/shared.ts | 11 ++--- 6 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 src/client/src/components/selectableRow.test.ts create mode 100644 src/client/src/components/selectableRow.ts diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index e86e465..80a7d7e 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -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 {

Projects

${this.projects.map((project) => html` -
- +
${this.openMenuProjectId === project.id ? html` diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 8070500..a998b24 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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` -
- +
${this.openMenuSessionId === session.id ? html` diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index e05d7ba..e02c0de 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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` -
-
+
{ activateSelectableRow(event, () => this.onSelect?.(workspace)); }} + @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace)); }} + > +
- + ${label} ${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))} ${workspace.path} diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts new file mode 100644 index 0000000..cdfa927 --- /dev/null +++ b/src/client/src/components/selectableRow.test.ts @@ -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): MouseEvent { + return { composedPath: () => [target] } as unknown as MouseEvent; +} + +function keyboardEventWithPath(key: string, target: Pick): KeyboardEvent & { preventDefault: ReturnType } { + return { key, preventDefault: vi.fn(), composedPath: () => [target] } as unknown as KeyboardEvent & { preventDefault: ReturnType }; +} diff --git a/src/client/src/components/selectableRow.ts b/src/client/src/components/selectableRow.ts new file mode 100644 index 0000000..9f7c9b8 --- /dev/null +++ b/src/client/src/components/selectableRow.ts @@ -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).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(); +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index ea2e2ce..f9df1e6 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }