Archived
feat: add compact mobile tab icons
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Use compact icons and initials for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling.
|
||||
@@ -46,7 +46,7 @@ import "./WorkspacePanel";
|
||||
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
|
||||
import "./appShell/AppContextBar";
|
||||
import "./appShell/AppMobileMainTabs";
|
||||
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
|
||||
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
|
||||
import "./appShell/AppNavigationPanel";
|
||||
import "./appShell/AppPanelEdgeControl";
|
||||
import "./appShell/AppRefreshControl";
|
||||
@@ -650,12 +650,19 @@ export class PiWebApp extends LitElement {
|
||||
return "Select a project and workspace to start a session.";
|
||||
}
|
||||
|
||||
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
|
||||
private mobilePanelBadge(panel: QualifiedWorkspacePanelContribution): unknown {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
if (workspace === undefined) return panel.title;
|
||||
const badge = panel.badge?.(this.createWorkspacePanelContext(workspace));
|
||||
if (badge === undefined || badge === "") return panel.title;
|
||||
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
|
||||
if (workspace === undefined) return undefined;
|
||||
return panel.badge?.(this.createWorkspacePanelContext(workspace));
|
||||
}
|
||||
|
||||
private mobilePanelIcon(panel: QualifiedWorkspacePanelContribution): AppMobileMainTabIcon | undefined {
|
||||
switch (panel.id) {
|
||||
case "core:workspace.files": return "files";
|
||||
case "core:workspace.git": return "git";
|
||||
case "core:workspace.terminal": return "terminal";
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||
@@ -1011,9 +1018,17 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private mobileMainTabs(): AppMobileMainTab[] {
|
||||
return [
|
||||
{ id: "navigation", label: "Sessions", className: "navigation-tab" },
|
||||
{ id: "chat", label: "Chat" },
|
||||
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })),
|
||||
{ id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" },
|
||||
{ id: "chat", label: "Chat", icon: "chat" },
|
||||
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => {
|
||||
const icon = this.mobilePanelIcon(panel);
|
||||
return {
|
||||
id: panel.id,
|
||||
label: panel.title,
|
||||
...(icon === undefined ? {} : { icon }),
|
||||
badge: this.mobilePanelBadge(panel),
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, svg } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { AppState } from "../../appState";
|
||||
|
||||
export type AppMobileMainTabIcon = "navigation" | "chat" | "files" | "git" | "terminal";
|
||||
|
||||
export interface AppMobileMainTab {
|
||||
id: AppState["mainView"];
|
||||
label: unknown;
|
||||
label: string;
|
||||
icon?: AppMobileMainTabIcon;
|
||||
badge?: unknown;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
@@ -37,12 +41,20 @@ export class AppMobileMainTabs extends LitElement {
|
||||
}
|
||||
|
||||
override render() {
|
||||
const fallbackLabels = this.fallbackLabels();
|
||||
return html`
|
||||
<div class=${this.frameClass()}>
|
||||
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
|
||||
${this.tabs.map((tab) => html`
|
||||
<button class=${this.tabClass(tab)} @click=${() => { this.onSelect?.(tab.id); }}>${tab.label}</button>
|
||||
`)}
|
||||
${this.tabs.map((tab) => {
|
||||
const selected = this.selectedView === tab.id;
|
||||
return html`
|
||||
<button class=${this.tabClass(tab)} title=${tab.label} aria-label=${this.tabAriaLabel(tab)} aria-pressed=${String(selected)} @click=${() => { this.onSelect?.(tab.id); }}>
|
||||
${this.renderTabMark(tab, fallbackLabels)}
|
||||
<span class="tab-label">${tab.label}</span>
|
||||
${this.isEmptyBadge(tab.badge) ? null : html`<span class="tab-badge">${tab.badge}</span>`}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -59,6 +71,98 @@ export class AppMobileMainTabs extends LitElement {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
private tabAriaLabel(tab: AppMobileMainTab): string {
|
||||
if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label;
|
||||
const badge = String(tab.badge).trim();
|
||||
return badge === "" ? tab.label : `${tab.label}, ${badge}`;
|
||||
}
|
||||
|
||||
private isEmptyBadge(badge: unknown): boolean {
|
||||
return badge === undefined || badge === "";
|
||||
}
|
||||
|
||||
private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map<AppState["mainView"], string>) {
|
||||
return tab.icon === undefined
|
||||
? html`<span class="tab-fallback" aria-hidden="true">${fallbackLabels.get(tab.id) ?? this.initialsLabel(tab.label)}</span>`
|
||||
: this.renderIcon(tab.icon);
|
||||
}
|
||||
|
||||
private fallbackLabels(): Map<AppState["mainView"], string> {
|
||||
const fallbackTabs = this.tabs.filter((tab) => tab.icon === undefined);
|
||||
const counts = new Map<string, number>();
|
||||
for (const tab of fallbackTabs) {
|
||||
const initials = this.initialsLabel(tab.label);
|
||||
counts.set(initials, (counts.get(initials) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const labels = new Map<AppState["mainView"], string>();
|
||||
for (const tab of fallbackTabs) {
|
||||
const initials = this.initialsLabel(tab.label);
|
||||
labels.set(tab.id, (counts.get(initials) ?? 0) > 1 ? this.fullFallbackLabel(tab.label) : initials);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private initialsLabel(label: string): string {
|
||||
const words = label.match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||
const initials = words.map((word) => Array.from(word)[0] ?? "").join("").toLocaleUpperCase();
|
||||
return initials === "" ? "?" : initials;
|
||||
}
|
||||
|
||||
private fullFallbackLabel(label: string): string {
|
||||
const trimmed = label.trim();
|
||||
return trimmed === "" ? "?" : trimmed;
|
||||
}
|
||||
|
||||
private renderIcon(icon: AppMobileMainTabIcon) {
|
||||
switch (icon) {
|
||||
case "navigation":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="6" cy="7" r="1.5"></circle>
|
||||
<path d="M10 7h8"></path>
|
||||
<circle cx="6" cy="12" r="1.5"></circle>
|
||||
<path d="M10 12h8"></path>
|
||||
<circle cx="6" cy="17" r="1.5"></circle>
|
||||
<path d="M10 17h8"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "chat":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M7 5h10a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-6l-5 4v-4H7a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z"></path>
|
||||
<path d="M8 9h8"></path>
|
||||
<path d="M8 13h5"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "files":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "git":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="6" cy="6" r="2"></circle>
|
||||
<circle cx="18" cy="6" r="2"></circle>
|
||||
<circle cx="12" cy="18" r="2"></circle>
|
||||
<path d="M8 6h6"></path>
|
||||
<path d="M6 8v2a6 6 0 0 0 6 6"></path>
|
||||
<path d="M18 8v2a6 6 0 0 1-6 6"></path>
|
||||
</svg>
|
||||
`;
|
||||
case "terminal":
|
||||
return svg`
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
|
||||
<path d="m7 10 3 3-3 3"></path>
|
||||
<path d="M12 16h5"></path>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private observeMobileTabs(): void {
|
||||
const mobileTabs = this.mobileTabsElement();
|
||||
if (this.observedMobileTabs === mobileTabs) return;
|
||||
@@ -98,13 +202,21 @@ export class AppMobileMainTabs extends LitElement {
|
||||
.mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
.mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; }
|
||||
.mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||
.mobile-tabs button { flex: 0 0 auto; white-space: nowrap; }
|
||||
.navigation-tab { display: none; }
|
||||
.mobile-tabs button { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.mobile-tabs .navigation-tab { display: none; }
|
||||
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
.tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
.tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; }
|
||||
.tab-label { min-width: 0; }
|
||||
.tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
@media (max-width: 760px) {
|
||||
.navigation-tab { display: block; }
|
||||
.mobile-tabs { gap: 4px; padding: 6px 8px; }
|
||||
.mobile-tabs button { position: relative; min-width: 40px; height: 36px; justify-content: center; gap: 0; padding: 0 10px; }
|
||||
.mobile-tabs .navigation-tab { display: inline-flex; }
|
||||
.tab-fallback { display: inline-block; }
|
||||
.tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
.tab-badge { position: absolute; top: 1px; right: 1px; min-width: 13px; padding: 0 3px; font-size: 10px; line-height: 13px; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user