sidebar improvements

This commit is contained in:
2026-03-13 19:01:56 -05:00
parent 6a1af4b723
commit f4ad67e097
3 changed files with 136 additions and 115 deletions
+1
View File
@@ -219,6 +219,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
>
<BuilderSidebar
isCollapsed={isSidebarCollapsed()}
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
unassignedItems={unassignedItems()}
grandTotals={grandTotals()}
hoveredScopeId={hoveredScopeId()}
@@ -1,6 +1,6 @@
import { createSignal, onCleanup, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { PanelLeftOpen, PanelLeftClose, Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal } from 'lucide-solid';
import { Calculator, Plus, Upload, Table, Download, History, Save, ChevronDown, MoreHorizontal } from 'lucide-solid';
import { appStore } from '../../store/appStore';
interface BuilderHeaderProps {
@@ -34,13 +34,6 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
return (
<div class="min-h-[4rem] py-3 px-6 border-b border-border bg-background/50 flex flex-wrap gap-y-4 items-center justify-between sticky top-0 z-50 backdrop-blur-md">
<div class="flex items-center gap-4">
<button
onClick={props.onToggleSidebar}
class="p-2 hover:bg-accent rounded-xl transition-colors text-muted-foreground shrink-0"
title={props.isSidebarCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{props.isSidebarCollapsed ? <PanelLeftOpen class="w-5 h-5" /> : <PanelLeftClose class="w-5 h-5" />}
</button>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary rounded-xl text-primary-foreground shadow-md shadow-primary/20 shrink-0">
<Calculator class="w-5 h-5" />
+134 -107
View File
@@ -1,11 +1,12 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { Plus, ListTree, ChevronRight } from 'lucide-solid';
import { Plus, ListTree, ChevronRight, PanelLeftOpen, PanelLeftClose } from 'lucide-solid';
import { appStore } from '../../store/appStore';
import type { EstimateItem } from '../../types';
interface BuilderSidebarProps {
isCollapsed: boolean;
onToggleSidebar: () => void;
unassignedItems: EstimateItem[];
grandTotals: { grandTotal: number };
hoveredScopeId: string | null;
@@ -24,123 +25,149 @@ export const BuilderSidebar: Component<BuilderSidebarProps> = (props) => {
return (
/*
* The sidebar sits inside a CSS grid column (in EstimateBuilder.tsx).
* It fills its column completely. When the column width transitions to 0,
* the sidebar content is clipped by overflow-hidden on this element.
* This means the main content genuinely reclaims the space.
* We use overflow-visible on the aside so the toggle button can remain
* visible and clickable even when the grid column width is 0px.
*/
<aside
class="h-[calc(100vh-var(--spacing-header))] sticky top-[var(--spacing-header)] bg-background border-r border-border flex flex-col overflow-hidden"
class="h-[calc(100vh-var(--spacing-header))] sticky top-[var(--spacing-header)] bg-background border-r border-border flex flex-col overflow-visible z-40 transition-colors"
style={{ "min-width": 0 }}
>
<div class="flex-1 overflow-y-auto overflow-x-hidden px-4 py-8 w-72">
<div class="mb-4">
<div class="flex items-center justify-between mb-4 px-2">
<span class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] whitespace-nowrap">Estimate Navigator</span>
<button
onClick={props.onAddScope}
class="p-1.5 bg-muted hover:bg-primary/10 text-muted-foreground hover:text-primary rounded-lg transition-all"
>
<Plus class="w-3 h-3" />
</button>
</div>
{/*
* Sticky Toggle Button
* Anchored to the right edge (100% width) of the sidebar column.
* As the column width animates, the button follows perfectly.
* We use translateX to adjust horizontal offset from the edge.
*/}
<button
onClick={props.onToggleSidebar}
class="absolute top-4 z-50 w-10 h-10 bg-background hover:bg-accent rounded-xl transition-all duration-500 ease-in-out shadow-premium border border-border text-muted-foreground hover:text-primary active:scale-95 flex items-center justify-center cursor-pointer"
style={{
left: '100%',
transform: props.isCollapsed
? 'translateX(1.25rem)' // When closed (0px width), sit 20px off the left edge
: 'translateX(-0.5rem)' // When open, hang 80% out to the right (overlap only 8px)
}}
title={props.isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{props.isCollapsed ? <PanelLeftOpen class="w-5 h-5" /> : <PanelLeftClose class="w-5 h-5" />}
</button>
<nav class="space-y-1">
{/* Unassigned Sidebar Link */}
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId('unassigned');
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, undefined, undefined)}
onClick={() => {
const el = document.getElementById('unassigned-section');
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
class={`group flex items-center justify-between px-3 py-3 rounded-xl text-xs font-black uppercase tracking-wider transition-all cursor-pointer border-2 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium text-primary' : (props.unassignedItems.length > 0 ? 'bg-muted/30 text-foreground border-border/50 hover:bg-muted/50' : 'text-muted-foreground border-transparent hover:bg-accent')}`}
>
<span class="flex items-center gap-2 whitespace-nowrap">
<ListTree class="w-4 h-4 shrink-0" /> Unassigned
</span>
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors shrink-0 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-primary border-border'}`}>
{props.unassignedItems.length}
</span>
{/*
* Sidebar Content Wrapper
* We apply the overflow-hidden here so the content doesn't leak out
* when the grid column is 0px.
*/}
<div class={`flex-1 overflow-y-auto overflow-x-hidden transition-all duration-500 flex flex-col ${props.isCollapsed ? 'w-0 opacity-0 pointer-events-none' : 'w-72 opacity-100'}`}>
<div class="flex-1 px-4 py-8">
<div class="mb-4">
<div class="flex items-center justify-between mb-4 px-2">
<span class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] whitespace-nowrap">Estimate Navigator</span>
<button
onClick={props.onAddScope}
class="p-1.5 bg-muted hover:bg-primary/10 text-muted-foreground hover:text-primary rounded-lg transition-all"
>
<Plus class="w-3 h-3" />
</button>
</div>
<div class="h-4"></div>
<nav class="space-y-1">
{/* Unassigned Sidebar Link */}
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId('unassigned');
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, undefined, undefined)}
onClick={() => {
const el = document.getElementById('unassigned-section');
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
class={`group flex items-center justify-between px-3 py-3 rounded-xl text-xs font-black uppercase tracking-wider transition-all cursor-pointer border-2 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium text-primary' : (props.unassignedItems.length > 0 ? 'bg-muted/30 text-foreground border-border/50 hover:bg-muted/50' : 'text-muted-foreground border-transparent hover:bg-accent')}`}
>
<span class="flex items-center gap-2 whitespace-nowrap">
<ListTree class="w-4 h-4 shrink-0" /> Unassigned
</span>
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors shrink-0 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-primary border-border'}`}>
{props.unassignedItems.length}
</span>
</div>
{/* Scopes Sidebar Links */}
<For each={scopes}>
{(scope) => (
<div class="space-y-1">
<div
onDragOver={(e) => { e.preventDefault(); props.setHoveredScopeId(scope.id); }}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, scope.id)}
class={`group flex items-center justify-between px-4 py-3 rounded-xl text-sm font-bold transition-all cursor-pointer border-2 ${props.hoveredScopeId === scope.id ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium text-primary' : 'text-muted-foreground border-transparent hover:bg-card hover:shadow-premium hover:border-border'}`}
onClick={() => {
const el = document.getElementById(`scope-${scope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
>
<span class="truncate pr-2">{scope.name}</span>
<div class="flex items-center gap-2 shrink-0">
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === scope.id ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted text-muted-foreground border-border'}`}>
{items.filter(i => i.scopeId === scope.id).length}
</span>
<ChevronRight class={`w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ${props.hoveredScopeId === scope.id ? 'text-primary' : ''}`} />
<div class="h-4"></div>
{/* Scopes Sidebar Links */}
<For each={scopes}>
{(scope) => (
<div class="space-y-1">
<div
onDragOver={(e) => { e.preventDefault(); props.setHoveredScopeId(scope.id); }}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, scope.id)}
class={`group flex items-center justify-between px-4 py-3 rounded-xl text-sm font-bold transition-all cursor-pointer border-2 ${props.hoveredScopeId === scope.id ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium text-primary' : 'text-muted-foreground border-transparent hover:bg-card hover:shadow-premium hover:border-border'}`}
onClick={() => {
const el = document.getElementById(`scope-${scope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
>
<span class="truncate pr-2">{scope.name}</span>
<div class="flex items-center gap-2 shrink-0">
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === scope.id ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted text-muted-foreground border-border'}`}>
{items.filter(i => i.scopeId === scope.id).length}
</span>
<ChevronRight class={`w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ${props.hoveredScopeId === scope.id ? 'text-primary' : ''}`} />
</div>
</div>
{/* Nested Sub-scopes in Sidebar */}
<div class="pl-4 space-y-0.5 border-l-2 border-gray-50 ml-6">
<For each={subScopes.filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<div
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); props.setHoveredScopeId(subScope.id); }}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => { e.stopPropagation(); props.onDrop(e, scope.id, subScope.id); }}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`subscope-${subScope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
class={`flex items-center justify-between px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wider transition-all cursor-pointer border ${props.hoveredScopeId === subScope.id ? 'bg-primary/5 border-primary/30 text-primary scale-[1.02]' : 'text-muted-foreground/60 border-transparent hover:bg-muted hover:text-foreground'}`}
>
<span class="truncate">{subScope.name}</span>
<span class="text-[10px] opacity-60 font-medium shrink-0">
{items.filter(i => i.subScopeId === subScope.id).length}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</nav>
</div>
{/* Nested Sub-scopes in Sidebar */}
<div class="pl-4 space-y-0.5 border-l-2 border-gray-50 ml-6">
<For each={subScopes.filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<div
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); props.setHoveredScopeId(subScope.id); }}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => { e.stopPropagation(); props.onDrop(e, scope.id, subScope.id); }}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`subscope-${subScope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
}}
class={`flex items-center justify-between px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wider transition-all cursor-pointer border ${props.hoveredScopeId === subScope.id ? 'bg-primary/5 border-primary/30 text-primary scale-[1.02]' : 'text-muted-foreground/60 border-transparent hover:bg-muted hover:text-foreground'}`}
>
<span class="truncate">{subScope.name}</span>
<span class="text-[10px] opacity-60 font-medium shrink-0">
{items.filter(i => i.subScopeId === subScope.id).length}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</nav>
</div>
{/* Sidebar Footer */}
<div class="p-4 border-t border-border bg-muted/30 rounded-2xl mt-4">
<div class="bg-card rounded-2xl p-4 border border-border shadow-sm">
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1 whitespace-nowrap">Current Total</div>
<div class="text-xl font-black text-foreground">${formatNumber(props.grandTotals.grandTotal)}</div>
{/* Sidebar Footer */}
<div class="p-4 border-t border-border bg-muted/30 rounded-2xl mt-4">
<div class="bg-card rounded-2xl p-4 border border-border shadow-sm">
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1 whitespace-nowrap">Current Total</div>
<div class="text-xl font-black text-foreground">${formatNumber(props.grandTotals.grandTotal)}</div>
</div>
</div>
</div>
</div>