Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/six-laws-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@valbuild/ui": patch
---

Updated look and feel of left navigation
7 changes: 7 additions & 0 deletions packages/ui/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from "react";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
import { TooltipProvider } from "../spa/components/designSystem/tooltip";

import "tailwindcss/tailwind.css";
import "../spa/index.css";
Expand All @@ -14,6 +16,11 @@ export const decorators = [
defaultTheme: "dark",
attributeName: "data-mode",
}),
// Many components (e.g. the NavMenu's error badges) use Radix Tooltip
// which requires a TooltipProvider ancestor. The live app mounts one in
// `ValProvider`; Storybook needs its own.
(Story) =>
React.createElement(TooltipProvider, null, React.createElement(Story)),
];

/** @type { import('@storybook/react').Preview } */
Expand Down
105 changes: 105 additions & 0 deletions packages/ui/spa/components/NavMenu/ErrorBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { ReactNode } from "react";
import { cn } from "../designSystem/cn";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../designSystem/tooltip";

export type ErrorBadgeProps = {
/** Total error count to display in the badge. */
count: number;
/** Number that resolve to this row itself (vs. descendants). Drives the
* tooltip wording. */
ownCount: number;
/** First error message — shown verbatim when ownCount > 0. */
firstMessage?: string;
/** Force the badge to render with smaller height (used in nested rows). */
size?: "sm" | "md";
/** Optional element wrapping the badge inside the tooltip trigger, for
* cases where the badge sits inside a button row. */
asChild?: boolean;
Comment on lines +19 to +21
/** Optional override className for the badge element. */
className?: string;
};

/**
* The red pill used everywhere in the nav menu to indicate validation
* errors. Tooltip on hover/focus shows the first error message (for own
* errors) or an aggregate hint (for descendants).
*
* Relies on the TooltipProvider mounted in `ValProvider`.
*/
export function ErrorBadge({
count,
ownCount,
firstMessage,
size = "sm",
className,
}: ErrorBadgeProps) {
if (count <= 0) return null;
const label = formatCount(count);
const tip = buildTooltip({ count, ownCount, firstMessage });
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"shrink-0 inline-flex items-center justify-center rounded-full",
"bg-bg-error-secondary text-fg-error-secondary",
"font-sans tabular-nums font-medium",
size === "sm"
? "min-w-[16px] h-[16px] px-1 text-[10px] leading-none"
: "min-w-[18px] h-[18px] px-1.5 text-[11px] leading-none",
className,
)}
aria-label={`${count} validation error${count === 1 ? "" : "s"}`}
>
{label}
</span>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-[280px] text-xs"
>
{tip}
</TooltipContent>
</Tooltip>
);
}

function formatCount(count: number): ReactNode {
if (count > 99) return "99+";
return count;
}

function buildTooltip({
count,
ownCount,
firstMessage,
}: {
count: number;
ownCount: number;
firstMessage?: string;
}): ReactNode {
if (ownCount > 0 && firstMessage) {
if (ownCount === 1 && count === 1) {
return <span>{firstMessage}</span>;
}
return (
<span>
<span className="block">{firstMessage}</span>
<span className="block text-fg-secondary mt-1">
{count === 1
? "1 error"
: ownCount === count
? `${count} errors`
: `${count} errors total (${ownCount} here)`}
</span>
</span>
);
}
// Aggregate-only: descendants carry the errors.
return <span>{count === 1 ? "1 error below" : `${count} errors below`}</span>;
}
137 changes: 80 additions & 57 deletions packages/ui/spa/components/NavMenu/ExplorerItem.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ChevronRight, Folder, FileText, AlertCircle } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { useState, useMemo } from "react";
import { cn } from "../designSystem/cn";
import { AnimateHeight } from "../AnimateHeight";
import { ExplorerItem as ExplorerItemType } from "./types";
import { prettifyFilename } from "../../utils/prettifyFilename";
import { ErrorBadge } from "./ErrorBadge";
import { totalExplorerErrorCount } from "./errorAggregation";

export type ExplorerItemProps = {
item: ExplorerItemType;
Expand All @@ -27,38 +28,28 @@ export function ExplorerItemNode({
const isActive = currentPath.startsWith(item.fullPath) && !item.isDirectory;
const isActiveParent =
currentPath.startsWith(item.fullPath) && item.isDirectory;
const isExactActive = currentPath === item.fullPath;

// Check if any children have errors (for collapsed indicator)
const hasDescendantError = useMemo(() => {
if (item.hasError) return true;
const checkChildren = (children: ExplorerItemType[]): boolean => {
return children.some(
(child) => child.hasError || checkChildren(child.children),
);
};
return checkChildren(item.children);
}, [item]);

const showErrorIndicator = item.hasError || (!isOpen && hasDescendantError);
// Errors that resolve directly to this file (zero for directories), plus
// the recursive total used by the count badge.
const ownErrorCount = item.errors?.ownCount ?? (item.hasError ? 1 : 0);
const totalErrorCount = useMemo(() => totalExplorerErrorCount(item), [item]);
const hasOwnError = ownErrorCount > 0;

// Sort: directories first, then alphabetically.
const sortedChildren = useMemo(() => {
// Sort: directories first, then alphabetically
return [...item.children].sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
}, [item.children]);

const handleClick = () => {
if (item.isDirectory) {
setIsOpen(!isOpen);
} else if (onNavigate) {
onNavigate(item.fullPath);
}
};
// Strip val module suffixes — `.val.ts`/`.val.js` are noise on every row.
// Other extensions are kept so users can still tell different file types apart.
const displayName = useMemo(() => stripValExtension(item.name), [item.name]);

// Skip root node rendering
// Skip the synthetic "/" root row — just render its children at depth 0.
if (item.name === "/" && depth === 0) {
return (
<>
Expand All @@ -75,57 +66,78 @@ export function ExplorerItemNode({
);
}

const handleClick = () => {
if (item.isDirectory) {
setIsOpen((prev) => !prev);
} else if (onNavigate) {
onNavigate(item.fullPath);
}
};

const indent = depth * 12 + 8;

return (
<div className="w-full">
<button
<div
className={cn(
"group flex items-center justify-between w-full h-9 pr-2 rounded-md transition-colors text-left",
"group relative flex items-center justify-between w-full h-8 pr-1.5 rounded-md transition-colors",
"hover:bg-bg-secondary",
{
"bg-bg-secondary": isActive,
"bg-bg-secondary": isActive || isActiveParent,
},
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={handleClick}
style={{ paddingLeft: `${indent}px` }}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{item.isDirectory ? (
{item.isDirectory && hasChildren ? (
<button
onClick={() => setIsOpen((prev) => !prev)}
className="shrink-0 mr-1 text-fg-secondary"
aria-label={isOpen ? "Collapse" : "Expand"}
>
<ChevronRight
size={14}
className={cn(
"shrink-0 text-fg-secondary transition-transform duration-200",
{
"rotate-90": isOpen,
invisible: !hasChildren,
},
)}
size={12}
className={cn("transition-transform duration-200", {
"rotate-90": isOpen,
})}
/>
) : (
<span className="w-3.5" />
)}

{item.isDirectory ? (
<Folder size={14} className="shrink-0 text-fg-secondary" />
) : (
<FileText size={14} className="shrink-0 text-fg-secondary" />
)}
</button>
) : (
<span className="shrink-0 w-3 mr-1" />
)}

<button
className="flex-1 min-w-0 flex items-center gap-1 py-1 text-left font-mono text-xs leading-none"
onClick={handleClick}
>
<span
className={cn("truncate text-sm", {
"font-medium": isActive,
"font-semibold": isActiveParent,
"text-fg-primary": !showErrorIndicator,
"text-fg-error-primary": showErrorIndicator,
className={cn("truncate", {
"font-medium": isExactActive,
})}
>
{prettifyFilename(item.name)}
<span
className={cn({
// Direct errors on a file tint its name red. Aggregated counts
// on a parent folder use only the badge — the folder name
// stays neutral.
"text-fg-error-primary": hasOwnError,
"text-fg-primary": !hasOwnError && !item.isDirectory,
"text-fg-secondary": !hasOwnError && item.isDirectory,
})}
>
{displayName}
{item.isDirectory && <span className="text-fg-secondary">/</span>}
</span>
</span>
</div>
</button>

{showErrorIndicator && (
<AlertCircle size={14} className="shrink-0 text-fg-error-primary" />
{totalErrorCount > 0 && (
<ErrorBadge
count={totalErrorCount}
ownCount={ownErrorCount}
firstMessage={item.errors?.firstMessage}
/>
)}
</button>
</div>

{hasChildren && (
<AnimateHeight isOpen={isOpen}>
Expand All @@ -145,3 +157,14 @@ export function ExplorerItemNode({
</div>
);
}

/**
* Strip the val module extensions (`.val.ts`, `.val.js`) so the display reads
* as a clean module name. Other extensions are kept so users can still
* distinguish file types.
*/
function stripValExtension(name: string): string {
if (name.endsWith(".val.ts")) return name.slice(0, -".val.ts".length);
if (name.endsWith(".val.js")) return name.slice(0, -".val.js".length);
return name;
}
11 changes: 11 additions & 0 deletions packages/ui/spa/components/NavMenu/ExplorerSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PanelsTopLeft } from "lucide-react";
import { useMemo } from "react";
import { cn } from "../designSystem/cn";
import { ExplorerItem } from "./types";
import { ExplorerItemNode } from "./ExplorerItem";
Expand All @@ -8,6 +9,8 @@ import {
AccordionTrigger,
} from "../designSystem/accordion";
import { ScrollArea } from "../designSystem/scroll-area";
import { ErrorBadge } from "./ErrorBadge";
import { totalExplorerErrorCount } from "./errorAggregation";

export type ExplorerSectionProps = {
/** Explorer data */
Expand All @@ -26,6 +29,11 @@ export function ExplorerSection({
onNavigate,
maxHeight = "100%",
}: ExplorerSectionProps) {
const sectionErrorCount = useMemo(
() => totalExplorerErrorCount(explorer),
[explorer],
);

return (
<AccordionItem value="explorer" className="border-b-0">
<AccordionTrigger
Expand All @@ -38,6 +46,9 @@ export function ExplorerSection({
<div className="flex items-center gap-2">
<PanelsTopLeft size={16} />
<span>Explorer</span>
{sectionErrorCount > 0 && (
<ErrorBadge count={sectionErrorCount} ownCount={0} size="sm" />
)}
</div>
</AccordionTrigger>
<AccordionContent className="pb-0">
Expand Down
Loading
Loading