Archived
Unify selectable navigation rows
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement, html, type PropertyValues } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { Project } from "../api";
|
import type { Project } from "../api";
|
||||||
|
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
|
|
||||||
@customElement("project-list")
|
@customElement("project-list")
|
||||||
@@ -35,10 +36,16 @@ export class ProjectList extends LitElement {
|
|||||||
<section>
|
<section>
|
||||||
<h2>Projects</h2>
|
<h2>Projects</h2>
|
||||||
${this.projects.map((project) => html`
|
${this.projects.map((project) => html`
|
||||||
<div class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}>
|
<div
|
||||||
<button class="action-main" @click=${() => this.onSelect?.(project)}>
|
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>
|
<span>${project.name}</span><small>${project.path}</small>
|
||||||
</button>
|
</div>
|
||||||
<div class="action-menu">
|
<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>
|
<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`
|
${this.openMenuProjectId === project.id ? html`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement, html, type PropertyValues } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||||
|
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
|
|
||||||
function sessionLabel(session: SessionInfo): string {
|
function sessionLabel(session: SessionInfo): string {
|
||||||
@@ -69,10 +70,17 @@ export class SessionList extends LitElement {
|
|||||||
const { session } = row;
|
const { session } = row;
|
||||||
const cappedDepth = Math.min(row.depth, 2);
|
const cappedDepth = Math.min(row.depth, 2);
|
||||||
return html`
|
return html`
|
||||||
<div class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}" style=${`--depth:${String(cappedDepth)}`}>
|
<div
|
||||||
<button class="action-main" @click=${() => this.onSelect?.(session)}>
|
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>
|
<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">
|
<div class="action-menu">
|
||||||
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
|
<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`
|
${this.openMenuSessionId === session.id ? html`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { LitElement, html } from "lit";
|
|||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import type { Workspace } from "../api";
|
import type { Workspace } from "../api";
|
||||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||||
|
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
import { renderWorkspaceLabelItems } from "./workspaceLabel";
|
import { renderWorkspaceLabelItems } from "./workspaceLabel";
|
||||||
|
|
||||||
@@ -19,10 +20,16 @@ export class WorkspaceList extends LitElement {
|
|||||||
${this.workspaces.map((workspace) => {
|
${this.workspaces.map((workspace) => {
|
||||||
const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`;
|
const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`;
|
||||||
return html`
|
return html`
|
||||||
<div class=${this.selected?.id === workspace.id ? "workspace-row selected" : "workspace-row"}>
|
<div
|
||||||
<div class="workspace-main">
|
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">
|
<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))}
|
${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))}
|
||||||
</span>
|
</span>
|
||||||
<small>${workspace.path}</small>
|
<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();
|
||||||
|
}
|
||||||
@@ -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; }
|
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; }
|
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; }
|
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; }
|
.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 { 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; }
|
.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.selected .action-main, .action-row.selected .action-menu-toggle { border-color: #58a6ff; background: #0d2847; }
|
||||||
.action-row.archived .action-main { color: #8b949e; }
|
.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; }
|
.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; }
|
.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; }
|
.action-menu { position: relative; align-self: stretch; }
|
||||||
|
|||||||
Reference in New Issue
Block a user