feat(tracker-web): SegmentedControl view toggle + board Tooltips (UX-12.1)

Replace the bespoke roadmap board/list toggle buttons (hardcoded blue-600) with
the shared SegmentedControl, and wrap truncated board-card titles in Tooltip.
Update the e2e toggle selector from button to radio for SegmentedControl.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
saravanakumardb1 2026-05-28 21:01:58 -07:00
parent 328e307212
commit ddf25cf501
4 changed files with 104 additions and 91 deletions

View File

@ -215,8 +215,11 @@ pnpm build # final gate
## UX-12 — Detail & board richness (Tabs · Tooltip · Drawer · Timeline · rich-text)
- [ ] **12.1** `/dashboard` board↔list: use `SegmentedControl` (or `Tabs`) for the view toggle
- [x] **12.1** `/dashboard` board↔list: use `SegmentedControl` (or `Tabs`) for the view toggle
instead of bespoke buttons; `Tooltip` on truncated titles / status dots.
(SegmentedControl now drives the roadmap board/list toggle — the app's only board↔list
toggle — replacing the bespoke `blue-600` buttons; `Tooltip` wraps the truncated board
card titles. e2e toggle selector updated button→radio. UX-12.1 verified: tc/lint/test 162 ✓/build/e2e 18 ✓)
- [ ] **12.2** Item detail: move row/item actions into an `ActionMenu`, and render the item's
activity/comment history with `Timeline`.
- [ ] **12.3** _(stretch — needs HTML-capable description/comment storage)_ Swap the plain

View File

@ -309,7 +309,8 @@ test.describe('Tracker — Public Roadmap', () => {
test('can toggle between board and list view', async ({ page }) => {
await page.goto('/roadmap');
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
await page.getByRole('button', { name: 'List', exact: true }).click();
// The view toggle is a shared SegmentedControl (role=radio), UX-12.1.
await page.getByRole('radio', { name: 'List', exact: true }).click();
// List view still shows the items (rendered as rows, not a <table>)
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible();

View File

@ -3,6 +3,12 @@
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { PageHeader } from '@bytelyst/dashboard-components';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import { listItems, updateItemStatus, type TrackerItem } from '@/lib/tracker-client';
@ -56,88 +62,95 @@ export default function BoardPage() {
};
return (
<div className="space-y-6">
<PageHeader
title="Board"
breadcrumbs={[{ label: 'Dashboard', href: '/dashboard' }, { label: 'Board' }]}
/>
<p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p>
<TooltipProvider>
<div className="space-y-6">
<PageHeader
title="Board"
breadcrumbs={[{ label: 'Dashboard', href: '/dashboard' }, { label: 'Board' }]}
/>
<p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p>
{error && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{error && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{COLUMNS.map(col => {
const colItems = items.filter(i => i.status === col.key);
return (
<div
key={col.key}
className={`rounded-xl border border-border border-t-4 ${col.color} bg-card p-3`}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">{col.label}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{colItems.length}
</span>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{COLUMNS.map(col => {
const colItems = items.filter(i => i.status === col.key);
return (
<div
key={col.key}
className={`rounded-xl border border-border border-t-4 ${col.color} bg-card p-3`}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">{col.label}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{colItems.length}
</span>
</div>
<div className="space-y-2">
{colItems.map(item => (
<div
key={item.id}
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
>
<div className="mb-1 flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${TYPE_DOT[item.type] || 'bg-gray-400'}`}
/>
<span className="text-xs text-muted-foreground">{item.type}</span>
<span
className={`ml-auto text-xs font-medium ${PRIORITY_LABEL[item.priority] || ''}`}
>
{item.priority}
</span>
</div>
<Link
href={`/dashboard/items/${item.id}`}
className="text-sm font-medium leading-tight hover:text-primary hover:underline"
<div className="space-y-2">
{colItems.map(item => (
<div
key={item.id}
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
>
{item.title}
</Link>
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
{item.voteCount > 0 && <span>{item.voteCount} votes</span>}
{item.commentCount > 0 && <span>{item.commentCount} comments</span>}
</div>
{/* Quick status move */}
<div className="mt-2 flex gap-1">
{COLUMNS.filter(c => c.key !== item.status).map(c => (
<button
key={c.key}
onClick={() => handleStatusChange(item.id, c.key)}
className="rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={`Move to ${c.label}`}
<div className="mb-1 flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${TYPE_DOT[item.type] || 'bg-gray-400'}`}
/>
<span className="text-xs text-muted-foreground">{item.type}</span>
<span
className={`ml-auto text-xs font-medium ${PRIORITY_LABEL[item.priority] || ''}`}
>
{c.label}
</button>
))}
</div>
</div>
))}
{item.priority}
</span>
</div>
{colItems.length === 0 && (
<div className="py-8 text-center text-xs text-muted-foreground">No items</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/dashboard/items/${item.id}`}
className="block truncate text-sm font-medium leading-tight hover:text-primary hover:underline"
>
{item.title}
</Link>
</TooltipTrigger>
<TooltipContent>{item.title}</TooltipContent>
</Tooltip>
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
{item.voteCount > 0 && <span>{item.voteCount} votes</span>}
{item.commentCount > 0 && <span>{item.commentCount} comments</span>}
</div>
{/* Quick status move */}
<div className="mt-2 flex gap-1">
{COLUMNS.filter(c => c.key !== item.status).map(c => (
<button
key={c.key}
onClick={() => handleStatusChange(item.id, c.key)}
className="rounded px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title={`Move to ${c.label}`}
>
{c.label}
</button>
))}
</div>
</div>
))}
{colItems.length === 0 && (
<div className="py-8 text-center text-xs text-muted-foreground">No items</div>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
</div>
</TooltipProvider>
);
}

View File

@ -1,6 +1,7 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { SegmentedControl } from '@/components/ui/Primitives';
import {
getRoadmapItems,
getRoadmapStats,
@ -264,20 +265,15 @@ export default function RoadmapPage() {
<option value="bug">Bugs</option>
<option value="task">Tasks</option>
</select>
<div className="flex border border-slate-300 dark:border-slate-600 rounded-lg overflow-hidden">
<button
onClick={() => setView('board')}
className={`px-3 py-2 text-sm ${view === 'board' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300'}`}
>
Board
</button>
<button
onClick={() => setView('list')}
className={`px-3 py-2 text-sm ${view === 'list' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300'}`}
>
List
</button>
</div>
<SegmentedControl
aria-label="Roadmap view"
value={view}
onValueChange={v => setView(v as 'board' | 'list')}
options={[
{ value: 'board', label: 'Board' },
{ value: 'list', label: 'List' },
]}
/>
</div>
{loading ? (