Skip to content

Commit 9b6cd7f

Browse files
GracePan-Tetraclaudeowilliams-tetrascienceCopilot
authored
feat: SW-2054 refine Data App Shell nav rail + workflow step styling (#138)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Oseer Williams (Small Fry) <265368733+owilliams-tetrascience@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 4d5f703 commit 9b6cd7f

2 files changed

Lines changed: 50 additions & 146 deletions

File tree

src/components/composed/DataAppShell/DataAppShell.stories.tsx

Lines changed: 40 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ function UserMenuButton({ name, userRole, expanded = false }: UserMenuButtonProp
8585
) : (
8686
<button type="button" className="cursor-pointer bg-transparent border-none p-0">
8787
<Avatar size="sm" className="bg-primary cursor-pointer hover:opacity-85 transition-opacity">
88-
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-semibold">
88+
<AvatarFallback className="bg-primary text-primary-foreground group-data-[size=sm]/avatar:text-[10px] font-semibold">
8989
{initials}
9090
</AvatarFallback>
9191
</Avatar>
@@ -132,17 +132,8 @@ interface WorkflowStep {
132132
onClick?: () => void;
133133
}
134134

135-
const MILLION = 1_000_000;
136-
const THOUSAND = 1_000;
137-
138-
function formatCount(n: number): string {
139-
if (n >= MILLION) return `${(n / MILLION).toFixed(n % MILLION === 0 ? 0 : 1)}M`;
140-
if (n >= THOUSAND) return `${(n / THOUSAND).toFixed(n % THOUSAND === 0 ? 0 : 1)}K`;
141-
return n.toLocaleString();
142-
}
143-
144135
const stepItemVariants = cva(
145-
"flex items-center gap-2 py-3.5 px-2.5 text-xs font-normal transition-colors duration-150 whitespace-nowrap leading-tight cursor-pointer border-l-[5px] w-full bg-transparent border-r-0 border-t-0 border-b-0",
136+
"flex items-center gap-2 py-2 px-2.5 font-normal transition-colors duration-150 whitespace-nowrap leading-tight cursor-pointer border-l-[5px] w-full bg-transparent border-r-0 border-t-0 border-b-0",
146137
{
147138
variants: {
148139
active: {
@@ -198,7 +189,7 @@ function WorkflowPanel({
198189
disabled={step.disabled}
199190
>
200191
{Icon ? (
201-
<Icon className={cn("w-5 h-5", step.isActive ? "text-primary" : "text-muted-foreground")} />
192+
<Icon className={cn("w-4 h-4", step.isActive ? "text-primary" : "text-muted-foreground")} />
202193
) : (
203194
<div
204195
className={cn(
@@ -261,18 +252,11 @@ function WorkflowPanel({
261252
step.isActive ? "text-primary" : "text-muted-foreground",
262253
)}
263254
>
264-
<Icon className="w-5 h-5" />
255+
<Icon className="w-4 h-4" />
265256
</span>
266257
)}
267-
<span className="flex flex-col items-start gap-0.5 min-w-0">
268-
<span className="truncate">{step.label}</span>
269-
{(step.inputCount != null || step.outputCount != null) && (
270-
<span className="text-[10px] text-muted-foreground font-normal tabular-nums">
271-
{step.inputCount != null && <span>{formatCount(step.inputCount)}</span>}
272-
{step.inputCount != null && step.outputCount != null && <span className="mx-0.5">{"\u2192"}</span>}
273-
{step.outputCount != null && <span>{formatCount(step.outputCount)}</span>}
274-
</span>
275-
)}
258+
<span className={cn("text-title-sm truncate min-w-0", !step.isActive && "font-light")}>
259+
{step.label}
276260
</span>
277261
</button>
278262
);
@@ -282,62 +266,6 @@ function WorkflowPanel({
282266
);
283267
}
284268

285-
// ── Data count pills ─────────────────────────────────────────────────────────
286-
287-
interface DataCount {
288-
label: string;
289-
count: number;
290-
variant?: "default" | "outline" | "primary";
291-
onClick?: () => void;
292-
}
293-
294-
const countPillVariants = cva(
295-
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium tabular-nums transition-colors",
296-
{
297-
variants: {
298-
variant: {
299-
default: "bg-muted text-muted-foreground",
300-
outline: "bg-transparent border border-border text-foreground",
301-
primary: "bg-primary/10 border border-primary/30 text-primary",
302-
},
303-
clickable: {
304-
true: "cursor-pointer hover:bg-primary/15",
305-
false: "cursor-default",
306-
},
307-
},
308-
defaultVariants: { variant: "default", clickable: false },
309-
},
310-
);
311-
312-
function DataCountPills({ dataCounts }: { dataCounts: DataCount[] }) {
313-
if (dataCounts.length === 0) return null;
314-
return (
315-
<div className="flex items-center gap-1.5">
316-
{dataCounts.map((dc, i) => {
317-
const pillClass = cn(countPillVariants({ variant: dc.variant ?? "outline", clickable: !!dc.onClick }));
318-
const pillContent = (
319-
<>
320-
<span className="text-muted-foreground text-[10px] uppercase tracking-wide font-medium">{dc.label}</span>
321-
<span className="font-semibold text-sm">{dc.count.toLocaleString()}</span>
322-
</>
323-
);
324-
return (
325-
<React.Fragment key={`${dc.label}-${i}`}>
326-
{i > 0 && <span className="text-muted-foreground/50 text-xs">{"\u2192"}</span>}
327-
{dc.onClick ? (
328-
<button type="button" className={pillClass} onClick={dc.onClick}>
329-
{pillContent}
330-
</button>
331-
) : (
332-
<div className={pillClass}>{pillContent}</div>
333-
)}
334-
</React.Fragment>
335-
);
336-
})}
337-
</div>
338-
);
339-
}
340-
341269
// =============================================================================
342270
// Meta
343271
// =============================================================================
@@ -368,16 +296,14 @@ const htsNavGroups: NavGroup[] = [
368296
const htsWorkflowSteps: WorkflowStep[] = [
369297
{
370298
id: "data-overview",
371-
label: "Data Overview",
299+
label: "Step 1 Name",
372300
icon: LayoutGrid,
373301
isActive: true,
374-
inputCount: 649568,
375-
outputCount: 645396,
376302
},
377-
{ id: "global-filtering", label: "Global Filtering", icon: Filter, inputCount: 645396, outputCount: 4803 },
378-
{ id: "explore-clusters", label: "Explore Clusters", icon: Library, inputCount: 3917, outputCount: 20 },
379-
{ id: "review-compound", label: "Review Selection", icon: Search, inputCount: 20, outputCount: 15 },
380-
{ id: "export-list", label: "Export Primary List", icon: Download, inputCount: 15 },
303+
{ id: "global-filtering", label: "Step 2 Name", icon: Filter },
304+
{ id: "explore-clusters", label: "Step 3 Name", icon: Library },
305+
{ id: "review-compound", label: "Step 4 Name", icon: Search },
306+
{ id: "export-list", label: "Step 5 Name", icon: Download },
381307
];
382308

383309
const htsBreadcrumbs = [
@@ -400,17 +326,9 @@ const DefaultShell = ({ initialCollapsed = false }: { initialCollapsed?: boolean
400326
onClick: () => setActiveStepId(s.id),
401327
}));
402328

403-
const activeStep = steps.find((s) => s.isActive);
404329
const activeStepIndex = steps.findIndex((s) => s.isActive);
405330
const isLastStep = activeStepIndex === steps.length - 1;
406-
407-
const dataCounts: DataCount[] =
408-
activeStep?.inputCount == null || activeStep?.outputCount == null
409-
? []
410-
: [
411-
{ label: "INPUT", count: activeStep.inputCount, variant: "outline" },
412-
{ label: "Output", count: activeStep.outputCount, variant: "primary" },
413-
];
331+
const isFirstStep = activeStepIndex <= 0;
414332

415333
return (
416334
<DataAppShell
@@ -424,7 +342,16 @@ const DefaultShell = ({ initialCollapsed = false }: { initialCollapsed?: boolean
424342
breadcrumbs={htsBreadcrumbs}
425343
headerActions={
426344
<>
427-
<DataCountPills dataCounts={dataCounts} />
345+
{!isFirstStep && (
346+
<Button
347+
variant="outline"
348+
size="sm"
349+
onClick={() => setActiveStepId(htsWorkflowSteps[activeStepIndex - 1].id)}
350+
className="gap-1"
351+
>
352+
Back
353+
</Button>
354+
)}
428355
<Button
429356
size="sm"
430357
disabled={isLastStep}
@@ -453,12 +380,11 @@ export const Default: Story = {
453380

454381
await step("Shell renders all regions", async () => {
455382
expect(canvas.getByText("HTS")).toBeInTheDocument();
456-
expect(canvas.getByText("Project")).toBeInTheDocument();
457-
expect(canvas.getByText("Explorer")).toBeInTheDocument();
383+
expect(canvas.getByRole("button", { name: "Project" })).toBeInTheDocument();
384+
expect(canvas.getByRole("button", { name: "Explorer" })).toBeInTheDocument();
458385
expect(canvas.getByText("Workflow")).toBeInTheDocument();
459-
expect(canvas.getAllByText("Data Overview").length).toBeGreaterThan(0);
386+
expect(canvas.getAllByText("Step 1 Name").length).toBeGreaterThan(0);
460387
expect(canvas.getAllByText("All Projects").length).toBeGreaterThan(0);
461-
expect(canvas.getByText("649,568")).toBeInTheDocument();
462388
expect(canvas.getByText("Next")).toBeInTheDocument();
463389
expect(canvas.getByText("Main content area")).toBeInTheDocument();
464390
});
@@ -486,7 +412,7 @@ export const CollapsedWorkflow: Story = {
486412
const canvas = within(canvasElement);
487413

488414
await step("Collapsed workflow — step labels hidden", async () => {
489-
expect(canvas.queryByText("Global Filtering")).not.toBeInTheDocument();
415+
expect(canvas.queryByText("Step 2 Name")).not.toBeInTheDocument();
490416
expect(canvas.queryByText("Workflow")).not.toBeInTheDocument();
491417
});
492418

@@ -571,16 +497,6 @@ const InteractiveShell = () => {
571497
const activeStep = steps.find((s) => s.isActive);
572498
const isProjectPage = activePageId === "project";
573499

574-
const dataCounts: DataCount[] =
575-
activeStep?.inputCount == null
576-
? []
577-
: [
578-
{ label: "INPUT", count: activeStep.inputCount, variant: "outline" },
579-
...(activeStep.outputCount == null
580-
? []
581-
: [{ label: "Output", count: activeStep.outputCount, variant: "primary" as const }]),
582-
];
583-
584500
return (
585501
<DataAppShell
586502
appName="HTS"
@@ -597,7 +513,6 @@ const InteractiveShell = () => {
597513
headerActions={
598514
isProjectPage && (
599515
<>
600-
<DataCountPills dataCounts={dataCounts} />
601516
<Button size="sm">Next</Button>
602517
</>
603518
)
@@ -627,7 +542,7 @@ export const Interactive: Story = {
627542

628543
await step("Interactive shell renders", async () => {
629544
expect(canvas.getByText("HTS")).toBeInTheDocument();
630-
expect(canvas.getAllByText("Data Overview").length).toBeGreaterThan(0);
545+
expect(canvas.getAllByText("Step 1 Name").length).toBeGreaterThan(0);
631546
});
632547
},
633548
parameters: {
@@ -836,11 +751,10 @@ export const WorkflowPanelInteractions: Story = {
836751
play: async ({ canvasElement, step }) => {
837752
const canvas = within(canvasElement);
838753

839-
await step("Expanded panel shows step labels and counts", async () => {
754+
await step("Expanded panel shows step labels", async () => {
840755
expect(canvas.getByText("Step Alpha")).toBeInTheDocument();
841756
expect(canvas.getByText("Step Beta")).toBeInTheDocument();
842757
expect(canvas.getByText("Disabled")).toBeInTheDocument();
843-
expect(canvas.getByText("1K")).toBeInTheDocument();
844758
});
845759

846760
await step("Clicking Step Beta makes it the active step", async () => {
@@ -916,8 +830,8 @@ export const MultipleNavGroups: Story = {
916830
const canvas = within(canvasElement);
917831

918832
await step("All pages from both groups are visible", async () => {
919-
expect(canvas.getByText("Project")).toBeInTheDocument();
920-
expect(canvas.getByText("Explorer")).toBeInTheDocument();
833+
expect(canvas.getByRole("button", { name: "Project" })).toBeInTheDocument();
834+
expect(canvas.getByRole("button", { name: "Explorer" })).toBeInTheDocument();
921835
expect(canvas.getAllByText("Filters").length).toBeGreaterThan(0);
922836
});
923837

@@ -931,7 +845,7 @@ export const MultipleNavGroups: Story = {
931845
await step("Active page icon has primary highlight", async () => {
932846
// Filters page is isActive — its icon container has bg-primary/10
933847
const rail = canvasElement.querySelector("[data-slot='data-app-sidebar-rail']");
934-
const activePage = within(rail!).getByText("Filters").closest("button");
848+
const activePage = within(rail!).getByRole("button", { name: "Filters" });
935849
const iconContainer = activePage?.querySelector(".bg-primary\\/10");
936850
expect(iconContainer).toBeInTheDocument();
937851
});
@@ -1228,16 +1142,16 @@ export const CompactProperty: Story = {
12281142
await step("Compact icon rail renders on desktop (hidden on mobile)", async () => {
12291143
const rail = canvasElement.querySelector("[data-slot='data-app-sidebar-rail']");
12301144
expect(rail).toBeInTheDocument();
1231-
// Icon rail should have width of 60px
1232-
expect(rail).toHaveClass("w-[60px]");
1145+
// Icon rail should have width of 48px
1146+
expect(rail).toHaveClass("w-12");
12331147
});
12341148

1235-
await step("Icon rail displays icons and labels stacked vertically", async () => {
1149+
await step("Icon rail displays icon-only nav buttons labelled via aria-label", async () => {
12361150
const rail = canvasElement.querySelector("[data-slot='data-app-sidebar-rail']");
1237-
// Labels should be present
1238-
expect(within(rail!).getByText("Project")).toBeInTheDocument();
1239-
expect(within(rail!).getByText("Explorer")).toBeInTheDocument();
1240-
expect(within(rail!).getByText("Filters")).toBeInTheDocument();
1151+
// Labels are exposed as accessible names (aria-label), not visible text
1152+
expect(within(rail!).getByRole("button", { name: "Project" })).toBeInTheDocument();
1153+
expect(within(rail!).getByRole("button", { name: "Explorer" })).toBeInTheDocument();
1154+
expect(within(rail!).getByRole("button", { name: "Filters" })).toBeInTheDocument();
12411155
});
12421156

12431157
await step("Group labels are hidden in compact icon rail", async () => {
@@ -1249,7 +1163,7 @@ export const CompactProperty: Story = {
12491163

12501164
await step("Active page has primary background highlight in compact mode", async () => {
12511165
const rail = canvasElement.querySelector("[data-slot='data-app-sidebar-rail']");
1252-
const projectBtn = within(rail!).getByText("Project").closest("button");
1166+
const projectBtn = within(rail!).getByRole("button", { name: "Project" });
12531167
const iconDiv = projectBtn?.querySelector(".bg-primary\\/10");
12541168
expect(iconDiv).toBeInTheDocument();
12551169
});
@@ -1268,7 +1182,7 @@ export const CompactProperty: Story = {
12681182
// Icon rail has md:flex which means it's hidden on mobile
12691183
expect(rail).toHaveClass("hidden", "md:flex");
12701184
const railStyles = window.getComputedStyle(rail!);
1271-
expect(railStyles.width).toBe("60px");
1185+
expect(railStyles.width).toBe("48px");
12721186
});
12731187

12741188
await step("User menu is visible at bottom of icon rail", async () => {

0 commit comments

Comments
 (0)