Archived
feat: simplify desktop navigation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Reduce desktop navigation crowding by moving machine switching into a compact header control and removing automatic desktop section collapse.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collapsedNavigationSectionsAfterSelection, defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState";
|
||||
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState";
|
||||
|
||||
describe("navigationState", () => {
|
||||
it("defaults to the first incomplete selection section", () => {
|
||||
@@ -51,10 +51,4 @@ describe("navigationState", () => {
|
||||
expect(toggleCollapsedNavigationSection(["sessions"], "machines")).toEqual(["machines", "sessions"]);
|
||||
});
|
||||
|
||||
it("collapses completed desktop sections and expands the next section after selection", () => {
|
||||
expect(collapsedNavigationSectionsAfterSelection([], "machines")).toEqual(["machines"]);
|
||||
expect(collapsedNavigationSectionsAfterSelection(["workspaces"], "projects")).toEqual(["machines", "projects"]);
|
||||
expect(collapsedNavigationSectionsAfterSelection([], "workspaces")).toEqual(["machines", "projects", "workspaces"]);
|
||||
expect(collapsedNavigationSectionsAfterSelection(["sessions"], "sessions")).toEqual(["machines", "projects", "workspaces"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,18 +41,6 @@ export function toggleCollapsedNavigationSection(collapsedSections: readonly Nav
|
||||
return orderedNavigationSections(collapsed);
|
||||
}
|
||||
|
||||
export function collapsedNavigationSectionsAfterSelection(collapsedSections: readonly NavigationSection[], selectedSection: NavigationSection): NavigationSection[] {
|
||||
const selectedIndex = NAVIGATION_SECTION_ORDER.indexOf(selectedSection);
|
||||
const collapsed = new Set(collapsedSections);
|
||||
const collapseThroughIndex = selectedSection === "sessions" ? selectedIndex - 1 : selectedIndex;
|
||||
for (const section of NAVIGATION_SECTION_ORDER.slice(0, collapseThroughIndex + 1)) collapsed.add(section);
|
||||
|
||||
const next = nextNavigationSection(selectedSection);
|
||||
if (next !== undefined) collapsed.delete(next);
|
||||
if (selectedSection === "sessions") collapsed.delete("sessions");
|
||||
return orderedNavigationSections(collapsed);
|
||||
}
|
||||
|
||||
export function nextNavigationSection(section: NavigationSection): NavigationSection | undefined {
|
||||
return NAVIGATION_SECTION_ORDER[NAVIGATION_SECTION_ORDER.indexOf(section) + 1];
|
||||
}
|
||||
@@ -103,12 +91,9 @@ export class NavigationSectionsController implements ReactiveController {
|
||||
}
|
||||
|
||||
advanceAfterSelection(section: NavigationSection): void {
|
||||
if (this.isMobileLayout()) {
|
||||
const next = nextNavigationSection(section);
|
||||
if (next !== undefined) this.expand(next);
|
||||
return;
|
||||
}
|
||||
this.setCollapsedSections(collapsedNavigationSectionsAfterSelection(this.collapsedSections, section));
|
||||
if (!this.isMobileLayout()) return;
|
||||
const next = nextNavigationSection(section);
|
||||
if (next !== undefined) this.expand(next);
|
||||
}
|
||||
|
||||
open(section: NavigationSection, openNavigationView: () => void): void {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { canRemoveMachine } from "./MachineList";
|
||||
|
||||
@customElement("machine-switcher")
|
||||
export class MachineSwitcher extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||
@state() private open = false;
|
||||
@state() private menuStyle = "";
|
||||
@state() private openActionsMachineId: string | undefined;
|
||||
@state() private actionMenuStyle = "";
|
||||
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
if (event.composedPath().includes(this)) return;
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.onDocumentClick);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.has("machines") && this.open && this.selectedMachine() === undefined) this.open = false;
|
||||
if (changed.has("machines") && this.openActionsMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openActionsMachineId)) this.openActionsMachineId = undefined;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const selected = this.selectedMachine();
|
||||
if (selected === undefined) return null;
|
||||
const status = machineStatus(selected, this.statuses);
|
||||
const label = selected.name;
|
||||
return html`
|
||||
<div class="machine-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="machine-switcher-button"
|
||||
title=${machineTitle(selected)}
|
||||
aria-label=${`Machine: ${label}. Switch machine.`}
|
||||
aria-expanded=${String(this.open)}
|
||||
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
|
||||
>
|
||||
${this.renderActivity(selected)}
|
||||
<span class="machine-switcher-text">
|
||||
<span class="machine-switcher-kicker">Machine</span>
|
||||
<span class="machine-switcher-label">${label}</span>
|
||||
</span>
|
||||
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
|
||||
<span class="machine-chevron" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
${this.open ? html`
|
||||
<div class="machine-switcher-menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
${this.machines.map((machine) => this.renderMachineOption(machine))}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMachineOption(machine: Machine): TemplateResult {
|
||||
const selected = this.selected?.id === machine.id;
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
const hasActions = canRemoveMachine(machine) && this.onRemove !== undefined;
|
||||
const actionsOpen = this.openActionsMachineId === machine.id;
|
||||
return html`
|
||||
<div class=${`machine-option ${selected ? "selected" : ""} ${hasActions ? "" : "no-actions"}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="machine-option-main"
|
||||
title=${machineTitle(machine)}
|
||||
@click=${() => { this.select(machine); }}
|
||||
>
|
||||
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
|
||||
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
|
||||
</button>
|
||||
${hasActions ? html`
|
||||
<div class="machine-option-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="machine-option-actions-toggle"
|
||||
title="Machine actions"
|
||||
aria-label=${`Actions for ${machine.name}`}
|
||||
aria-expanded=${String(actionsOpen)}
|
||||
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleActionsMenu(machine.id, event.currentTarget); }}
|
||||
>⋯</button>
|
||||
${actionsOpen ? html`
|
||||
<div class="machine-option-actions-panel" style=${this.actionMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActivity(machine: Machine): TemplateResult | undefined {
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private selectedMachine(): Machine | undefined {
|
||||
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
|
||||
}
|
||||
|
||||
private toggleMenu(target: EventTarget | null): void {
|
||||
this.menuStyle = machineSwitcherMenuStyle(target);
|
||||
this.open = !this.open;
|
||||
this.openActionsMachineId = undefined;
|
||||
}
|
||||
|
||||
private toggleActionsMenu(machineId: string, target: EventTarget | null): void {
|
||||
if (this.openActionsMachineId === machineId) {
|
||||
this.openActionsMachineId = undefined;
|
||||
return;
|
||||
}
|
||||
this.actionMenuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
|
||||
this.openActionsMachineId = machineId;
|
||||
}
|
||||
|
||||
private select(machine: Machine): void {
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
void this.onSelect?.(machine);
|
||||
}
|
||||
|
||||
private removeMachine(machine: Machine): void {
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
void this.onRemove?.(machine);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { min-width: 0; display: block; }
|
||||
.machine-switcher { min-width: 0; }
|
||||
.machine-switcher-button { box-sizing: border-box; width: 100%; min-width: 0; display: flex; align-items: center; gap: 6px; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 8px; cursor: pointer; text-align: left; }
|
||||
.machine-switcher-button:hover, .machine-switcher-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.machine-switcher-text { min-width: 0; display: grid; gap: 1px; }
|
||||
.machine-switcher-kicker { color: var(--pi-muted); font-size: 10px; line-height: 1; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.machine-switcher-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; line-height: 1.2; }
|
||||
.machine-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; }
|
||||
.machine-status.online { color: var(--pi-success); }
|
||||
.machine-status.offline, .machine-status.error { color: var(--pi-danger); }
|
||||
.machine-chevron { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; }
|
||||
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
|
||||
.machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; }
|
||||
.machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
|
||||
.machine-option-main, .machine-option-actions-toggle, .machine-option-actions-panel button { border: 0; border-radius: 7px; background: transparent; color: var(--pi-text); cursor: pointer; }
|
||||
.machine-option-main { min-width: 0; display: grid; gap: 2px; padding: 7px 8px; text-align: left; }
|
||||
.machine-option-name { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.machine-option-name span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-option-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-muted); }
|
||||
.machine-option-actions { position: relative; align-self: stretch; }
|
||||
.machine-option-actions-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); }
|
||||
.machine-option-actions-panel { position: fixed; z-index: 10001; box-sizing: border-box; min-width: min(120px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
|
||||
.machine-option-actions-panel button { display: block; width: 100%; padding: 7px 9px; text-align: left; white-space: nowrap; }
|
||||
.machine-option-actions-panel button.danger { color: var(--pi-danger); }
|
||||
.machine-option-main:hover, .machine-option-main:focus-visible, .machine-option-actions-toggle:hover, .machine-option-actions-toggle:focus-visible, .machine-option.selected .machine-option-main { background: var(--pi-selection-bg); }
|
||||
.machine-option-actions-panel button:hover, .machine-option-actions-panel button:focus-visible { background: var(--pi-selection-bg); }
|
||||
.machine-option-actions-panel button.danger:hover, .machine-option-actions-panel button.danger:focus-visible { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
@keyframes pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||||
`;
|
||||
}
|
||||
|
||||
export function shouldShowMachineSwitcher(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
|
||||
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
|
||||
}
|
||||
|
||||
function machineStatusLabel(status: MachineStatus): string {
|
||||
return status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown";
|
||||
}
|
||||
|
||||
function machineTitle(machine: Machine): string {
|
||||
return machine.baseUrl ?? machine.name;
|
||||
}
|
||||
|
||||
function machineSwitcherMenuStyle(target: EventTarget | null): string {
|
||||
if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return "";
|
||||
const trigger = target.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
const menuWidth = Math.min(280, Math.max(0, window.innerWidth - viewportPadding * 2));
|
||||
const left = Math.min(Math.max(viewportPadding, trigger.left), Math.max(viewportPadding, window.innerWidth - viewportPadding - menuWidth));
|
||||
const availableBelow = Math.max(0, window.innerHeight - trigger.bottom - viewportPadding);
|
||||
return [`top: ${px(trigger.bottom)};`, `left: ${px(left)};`, `width: ${px(menuWidth)};`, `max-height: ${px(availableBelow)};`].join(" ");
|
||||
}
|
||||
|
||||
function px(value: number): string {
|
||||
return `${String(Math.round(value))}px`;
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import type { Machine } from "../../api";
|
||||
import { shouldShowMachinesSection } from "./AppNavigationPanel";
|
||||
|
||||
describe("shouldShowMachinesSection", () => {
|
||||
it("hides the machines section when there is no machine choice", () => {
|
||||
it("hides machine navigation when there is no machine choice", () => {
|
||||
expect(shouldShowMachinesSection([])).toBe(false);
|
||||
expect(shouldShowMachinesSection([machine("local")])).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the machines section when there are multiple machines", () => {
|
||||
it("shows machine navigation when there are multiple machines", () => {
|
||||
expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||
import "../MachineList";
|
||||
import "../MachineSwitcher";
|
||||
import "../ProjectList";
|
||||
import "../WorkspaceList";
|
||||
import "../SessionList";
|
||||
@@ -57,12 +58,22 @@ export class AppNavigationPanel extends LitElement {
|
||||
return html`
|
||||
<header>
|
||||
<strong>PI WEB</strong>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-switcher
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.activities=${this.machineActivities}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
></machine-switcher>
|
||||
` : null}
|
||||
<div class="header-actions">
|
||||
${this.refreshControl}
|
||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
||||
</div>
|
||||
</header>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
${this.compact && shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-list
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
@@ -123,8 +134,10 @@ export class AppNavigationPanel extends LitElement {
|
||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
:host([compact]) { flex: 1 1 auto; }
|
||||
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
||||
header strong { flex: 0 0 auto; }
|
||||
machine-switcher { flex: 1 1 auto; min-width: 0; }
|
||||
:host([compact]) header { display: none; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
|
||||
machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
machine-list[collapsed],
|
||||
|
||||
Reference in New Issue
Block a user