1,888 lines · 4 files · 47.1 kB
cases/127-shadcn-dashboard/dashboard.tsx633 lines · dependency
cases/127-shadcn-dashboard/dashboard.tsx
import { useEffect, useMemo, useState } from 'react'
import {
dashboardTableRows,
filterDashboardData,
formatDashboardDate,
type DashboardDatum,
type DashboardRange,
} from './data'
import { shadcnDashboardStyles } from './styles'
import type { ComponentType, ReactNode, SVGProps } from 'react'
export interface DashboardSize {
width: number
height: number
}
export interface DashboardChartProps {
data: readonly DashboardDatum[]
input: DashboardSize
}
export function dashboardChartWidth(width: number): number {
if (width <= 480) return Math.max(1, width - 50)
if (width < 768) return Math.max(1, width - 82)
return Math.max(1, width - 354)
}
export function dashboardTickValues(
data: readonly DashboardDatum[],
width: number,
): readonly string[] {
if (data.length < 2) return data.map((datum) => datum.date)
const labels = data.map((datum) => formatDashboardDate(datum.date))
const context =
typeof document === 'undefined' ||
typeof CanvasRenderingContext2D === 'undefined'
? null
: document.createElement('canvas').getContext('2d')
if (context) {
context.font =
'12px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
}
const measure = (label: string) =>
context?.measureText(label).width ?? label.length * 6.1
const start = 5
const axisLength = Math.max(1, width - 10)
let end = start + axisLength
const visible: string[] = []
for (let index = data.length - 1; index >= 0; index -= 1) {
const size = measure(labels[index]!)
const coordinate = start + (axisLength * index) / (data.length - 1)
const tickCoordinate =
index === data.length - 1
? Math.min(coordinate, end - size / 2)
: coordinate
if (
tickCoordinate - size / 2 >= start &&
tickCoordinate + size / 2 <= end
) {
visible.push(data[index]!.date)
end = tickCoordinate - size / 2 - 32
}
}
return visible.reverse()
}
interface DashboardProps {
ChartRenderer: ComponentType<DashboardChartProps>
input: DashboardSize
}
const ranges: readonly { value: DashboardRange; label: string }[] = [
{ value: '90d', label: 'Last 3 months' },
{ value: '30d', label: 'Last 30 days' },
{ value: '7d', label: 'Last 7 days' },
]
const statCards = [
{
label: 'Total Revenue',
value: '$1,250.00',
change: '+12.5%',
trend: 'Trending up this month',
detail: 'Visitors for the last 6 months',
direction: 'up' as const,
},
{
label: 'New Customers',
value: '1,234',
change: '-20%',
trend: 'Down 20% this period',
detail: 'Acquisition needs attention',
direction: 'down' as const,
},
{
label: 'Active Accounts',
value: '45,678',
change: '+12.5%',
trend: 'Strong user retention',
detail: 'Engagement exceed targets',
direction: 'up' as const,
},
{
label: 'Growth Rate',
value: '4.5%',
change: '+4.5%',
trend: 'Steady performance increase',
detail: 'Meets growth projections',
direction: 'up' as const,
},
]
const mainNavigation = [
['Dashboard', 'dashboard'],
['Lifecycle', 'list'],
['Analytics', 'analytics'],
['Projects', 'folder'],
['Team', 'users'],
] as const
const documentNavigation = [
['Data Library', 'database'],
['Reports', 'report'],
['Word Assistant', 'word'],
] as const
const secondaryNavigation = [
['Settings', 'settings'],
['Get Help', 'help'],
['Search', 'search'],
] as const
export function ShadcnDashboard({ ChartRenderer, input }: DashboardProps) {
const [range, setRange] = useState<DashboardRange>(
input.width < 768 ? '7d' : '90d',
)
const chartData = useMemo(() => filterDashboardData(range), [range])
useEffect(() => {
if (input.width < 768) setRange('7d')
}, [input.width])
return (
<div
className="shadcn-dashboard"
data-conformance-view="main"
data-range={range}
role="region"
aria-label="shadcn dashboard example"
style={{ width: input.width, height: input.height }}
>
<style>{shadcnDashboardStyles}</style>
<div className="sd-viewport">
<DashboardSidebar />
<main className="sd-main">
<div className="sd-main-scroll">
<DashboardHeader />
<div className="sd-content">
<section className="sd-cards" aria-label="Key metrics">
{statCards.map((card) => (
<StatCard key={card.label} {...card} />
))}
</section>
<section className="sd-card sd-chart-card">
<header className="sd-chart-header">
<h2 className="sd-chart-title">Total Visitors</h2>
<p className="sd-chart-description">
Total for the last 3 months
</p>
<div
className="sd-range-buttons"
role="group"
aria-label="Chart time range"
>
{ranges.map((option) => (
<button
key={option.value}
type="button"
className="sd-range-button"
data-active={range === option.value}
data-range-value={option.value}
aria-pressed={range === option.value}
onClick={() => setRange(option.value)}
>
{option.label}
</button>
))}
</div>
<select
className="sd-range-select"
aria-label="Select a time range"
value={range}
onChange={(event) =>
setRange(event.currentTarget.value as DashboardRange)
}
>
{ranges.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</header>
<div className="sd-chart-stage">
<ChartRenderer data={chartData} input={input} />
</div>
</section>
<DashboardTable />
</div>
</div>
</main>
</div>
</div>
)
}
function DashboardSidebar() {
return (
<aside className="sd-sidebar" aria-label="Sidebar">
<div className="sd-sidebar-inner">
<div className="sd-brand">
<Icon name="brand" className="sd-brand-mark" />
<span className="sd-brand-name">Acme Inc.</span>
</div>
<nav className="sd-nav" aria-label="Dashboard">
<div className="sd-nav-group">
<div className="sd-nav-label">Home</div>
{mainNavigation.map(([label, icon], index) => (
<NavItem
key={label}
label={label}
icon={icon}
active={index === 0}
/>
))}
</div>
<div className="sd-nav-group">
<div className="sd-nav-label">Documents</div>
{documentNavigation.map(([label, icon]) => (
<NavItem key={label} label={label} icon={icon} more />
))}
<NavItem label="More" icon="more" />
</div>
<div className="sd-nav-group sd-nav-bottom">
{secondaryNavigation.map(([label, icon]) => (
<NavItem key={label} label={label} icon={icon} />
))}
</div>
</nav>
<div className="sd-user">
<span className="sd-avatar">CN</span>
<span className="sd-user-copy">
<strong>shadcn</strong>
<span>m@example.com</span>
</span>
<Icon name="more-vertical" className="sd-nav-more" />
</div>
</div>
</aside>
)
}
function DashboardHeader() {
return (
<header className="sd-header">
<button
className="sd-icon-button sd-menu-button"
type="button"
aria-label="Toggle menu"
>
<Icon name="menu" />
</button>
<span className="sd-separator" aria-hidden="true" />
<h1>Documents</h1>
<button className="sd-quick-create" type="button">
<Icon name="plus" />
Quick Create
</button>
</header>
)
}
function NavItem({
label,
icon,
active = false,
more = false,
}: {
label: string
icon: IconName
active?: boolean
more?: boolean
}) {
return (
<button
className="sd-nav-item"
type="button"
data-active={active}
aria-current={active ? 'page' : undefined}
>
<Icon name={icon} />
<span>{label}</span>
{more ? <Icon name="more" className="sd-nav-more" /> : null}
</button>
)
}
function StatCard({
label,
value,
change,
trend,
detail,
direction,
}: (typeof statCards)[number]) {
return (
<article className="sd-card sd-stat-card">
<div className="sd-stat-head">
<span className="sd-stat-label">{label}</span>
<span className="sd-badge">
<Icon name={direction === 'up' ? 'trend-up' : 'trend-down'} />
{change}
</span>
<p className="sd-stat-value">{value}</p>
</div>
<div className="sd-stat-foot">
<span className="sd-stat-trend">
{trend}
<Icon name={direction === 'up' ? 'trend-up' : 'trend-down'} />
</span>
<span className="sd-stat-detail">{detail}</span>
</div>
</article>
)
}
function DashboardTable() {
return (
<section className="sd-table-section" aria-label="Document sections">
<div className="sd-toolbar">
<div className="sd-tabs" role="tablist" aria-label="Document view">
<button
className="sd-tab"
type="button"
role="tab"
data-active="true"
>
Outline
</button>
<button className="sd-tab" type="button" role="tab">
Past Performance <span className="sd-badge">3</span>
</button>
<button className="sd-tab" type="button" role="tab">
Key Personnel <span className="sd-badge">2</span>
</button>
<button className="sd-tab" type="button" role="tab">
Focus Documents
</button>
</div>
<div className="sd-toolbar-actions">
<button className="sd-button" type="button">
<Icon name="columns" />
<span>Customize Columns</span>
<Icon name="chevron-down" />
</button>
<button className="sd-button" type="button">
<Icon name="plus" />
<span>Add Section</span>
</button>
</div>
</div>
<div className="sd-table-wrap">
<div className="sd-table-scroll">
<table className="sd-table">
<thead>
<tr>
<th aria-label="Reorder" />
<th>
<input
className="sd-checkbox"
type="checkbox"
aria-label="Select all"
/>
</th>
<th>Header</th>
<th>Section Type</th>
<th>Status</th>
<th>Target</th>
<th>Limit</th>
<th>Reviewer</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{dashboardTableRows.map((row) => (
<tr key={row.id}>
<td>
<span className="sd-grip" aria-hidden="true">
⋮⋮
</span>
</td>
<td>
<input
className="sd-checkbox"
type="checkbox"
aria-label={`Select ${row.header}`}
/>
</td>
<td>
<a className="sd-table-link" href="#">
{row.header}
</a>
</td>
<td>
<span className="sd-badge">{row.type}</span>
</td>
<td>
<span
className="sd-badge sd-status"
data-done={row.status === 'Done'}
>
<span className="sd-status-dot" />
{row.status}
</span>
</td>
<td>
<input
className="sd-number-input"
aria-label={`${row.header} target`}
defaultValue={row.target}
/>
</td>
<td>
<input
className="sd-number-input"
aria-label={`${row.header} limit`}
defaultValue={row.limit}
/>
</td>
<td>{row.reviewer}</td>
<td>
<button
className="sd-icon-button"
type="button"
aria-label={`Open ${row.header} menu`}
>
<Icon name="more-vertical" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="sd-table-footer">
<span>0 of 68 row(s) selected.</span>
<span className="sd-pagination">
<span>Rows per page</span>
<button className="sd-button" type="button">
10 <Icon name="chevron-down" />
</button>
<span>Page 1 of 7</span>
<button
className="sd-icon-button"
type="button"
aria-label="Previous page"
>
<Icon name="chevron-left" />
</button>
<button
className="sd-icon-button"
type="button"
aria-label="Next page"
>
<Icon name="chevron-right" />
</button>
</span>
</div>
</section>
)
}
type IconName =
| 'analytics'
| 'brand'
| 'chevron-down'
| 'chevron-left'
| 'chevron-right'
| 'chevrons'
| 'columns'
| 'dashboard'
| 'database'
| 'folder'
| 'help'
| 'list'
| 'menu'
| 'more'
| 'more-vertical'
| 'plus'
| 'report'
| 'search'
| 'settings'
| 'trend-down'
| 'trend-up'
| 'users'
| 'word'
function Icon({
name,
...props
}: SVGProps<SVGSVGElement> & { name: IconName }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{iconPaths[name]}
</svg>
)
}
const iconPaths: Record<IconName, ReactNode> = {
brand: (
<>
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" />
<path d="M8 9h8v6H8z" />
</>
),
menu: (
<>
<path d="M4 5h16v14H4z" />
<path d="M9 5v14" />
</>
),
dashboard: (
<>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</>
),
list: (
<>
<path d="M9 6h11M9 12h11M9 18h11" />
<path d="M4 6h.01M4 12h.01M4 18h.01" />
</>
),
analytics: (
<>
<path d="M4 19V9M10 19V5M16 19v-7M22 19H2" />
</>
),
folder: <path d="M3 6h7l2 2h9v11H3z" />,
users: (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3 20c0-4 2-6 6-6s6 2 6 6M16 5a3 3 0 010 6M17 14c2.7.3 4 2.3 4 5" />
</>
),
database: (
<>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
</>
),
report: (
<>
<path d="M5 3h14v18H5zM9 7h6M9 11h6M9 15h4" />
</>
),
word: (
<>
<path d="M5 3h10l4 4v14H5zM15 3v5h4" />
<path d="M8 11l1.5 6 2.5-4 2.5 4 1.5-6" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19 12a7 7 0 00-.1-1l2-1.6-2-3.4-2.5 1a7 7 0 00-1.7-1L14.3 3h-4.6l-.4 3a7 7 0 00-1.7 1L5 6 3 9.4 5.1 11a7 7 0 000 2L3 14.6 5 18l2.6-1a7 7 0 001.7 1l.4 3h4.6l.4-3a7 7 0 001.7-1l2.5 1 2-3.4-2-1.6a7 7 0 00.1-1z" />
</>
),
help: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.7 2.7 0 115 1.5c-.8 1.2-2.5 1.4-2.5 3M12 17h.01" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="M16.5 16.5L21 21" />
</>
),
more: (
<>
<circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" />
</>
),
'more-vertical': (
<>
<circle cx="12" cy="5" r="1" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1" fill="currentColor" stroke="none" />
</>
),
chevrons: <path d="M8 9l4-4 4 4M16 15l-4 4-4-4" />,
'trend-up': <path d="M3 17l6-6 4 4 8-8M15 7h6v6" />,
'trend-down': <path d="M3 7l6 6 4-4 8 8M15 17h6v-6" />,
columns: (
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M9 4v16M15 4v16" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
'chevron-down': <path d="M7 9l5 5 5-5" />,
'chevron-left': <path d="M15 6l-6 6 6 6" />,
'chevron-right': <path d="M9 6l6 6-6 6" />,
}cases/127-shadcn-dashboard/data.ts256 lines · dependency
cases/127-shadcn-dashboard/data.ts
export type DashboardRange = '90d' | '30d' | '7d'
export type DashboardSeries = 'mobile' | 'desktop'
export interface DashboardDatum {
date: string
desktop: number
mobile: number
}
export interface DashboardAreaDatum {
date: Date
dateKey: string
series: DashboardSeries
visitors: number
}
export interface DashboardTableRow {
id: number
header: string
type: string
status: 'Done' | 'In Process'
target: string
limit: string
reviewer: string
}
export const dashboardChartData: readonly DashboardDatum[] = [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
]
export const dashboardTableRows: readonly DashboardTableRow[] = [
{
id: 1,
header: 'Cover page',
type: 'Cover page',
status: 'In Process',
target: '18',
limit: '5',
reviewer: 'Eddie Lake',
},
{
id: 2,
header: 'Table of contents',
type: 'Table of contents',
status: 'Done',
target: '29',
limit: '24',
reviewer: 'Eddie Lake',
},
{
id: 3,
header: 'Executive summary',
type: 'Narrative',
status: 'Done',
target: '10',
limit: '13',
reviewer: 'Eddie Lake',
},
{
id: 4,
header: 'Technical approach',
type: 'Narrative',
status: 'Done',
target: '27',
limit: '23',
reviewer: 'Jamik Tashpulatov',
},
{
id: 5,
header: 'Design',
type: 'Narrative',
status: 'In Process',
target: '2',
limit: '16',
reviewer: 'Jamik Tashpulatov',
},
{
id: 6,
header: 'Capabilities',
type: 'Narrative',
status: 'In Process',
target: '20',
limit: '8',
reviewer: 'Jamik Tashpulatov',
},
{
id: 7,
header: 'Integration with existing systems',
type: 'Narrative',
status: 'In Process',
target: '19',
limit: '21',
reviewer: 'Jamik Tashpulatov',
},
{
id: 8,
header: 'Innovation and Advantages',
type: 'Narrative',
status: 'Done',
target: '25',
limit: '26',
reviewer: 'Assign reviewer',
},
{
id: 9,
header: "Overview of EMR's Innovative Solutions",
type: 'Technical content',
status: 'Done',
target: '7',
limit: '23',
reviewer: 'Assign reviewer',
},
{
id: 10,
header: 'Advanced Algorithms and Machine Learning',
type: 'Narrative',
status: 'Done',
target: '30',
limit: '28',
reviewer: 'Assign reviewer',
},
]
const referenceDate = Date.parse('2024-06-30T00:00:00Z')
const day = 24 * 60 * 60 * 1_000
export function filterDashboardData(
range: DashboardRange,
): readonly DashboardDatum[] {
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
const start = referenceDate - days * day
return dashboardChartData.filter(
(datum) => Date.parse(`${datum.date}T00:00:00Z`) >= start,
)
}
export function dashboardAreaRows(
data: readonly DashboardDatum[],
): readonly DashboardAreaDatum[] {
return data.flatMap((datum) => {
const date = new Date(`${datum.date}T00:00:00Z`)
return [
{
date,
dateKey: datum.date,
series: 'mobile' as const,
visitors: datum.mobile,
},
{
date,
dateKey: datum.date,
series: 'desktop' as const,
visitors: datum.desktop,
},
]
})
}
export function formatDashboardDate(value: string | number | Date): string {
const date = value instanceof Date ? value : new Date(value)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
}cases/127-shadcn-dashboard/example.tsx192 lines · entry
cases/127-shadcn-dashboard/example.tsx
import { useMemo } from 'react'
import { areaY, d3Curve, defineChart, stack } from '@tanstack/charts'
import { motion } from '@tanstack/charts/motion'
import { Chart } from '@tanstack/charts/react/core'
import { tooltip } from '@tanstack/charts/tooltip'
import { scaleLinear, scalePoint } from 'd3-scale'
import { curveNatural } from 'd3-shape'
import {
dashboardChartWidth,
dashboardTickValues,
ShadcnDashboard,
type DashboardChartProps,
} from './dashboard'
import {
dashboardAreaRows,
filterDashboardData,
formatDashboardDate,
type DashboardAreaDatum,
type DashboardDatum,
type DashboardSeries,
} from './data'
import type { ChartTooltipContent, ChartTooltipOptions } from '@tanstack/charts'
const seriesOrder: readonly DashboardSeries[] = ['mobile', 'desktop']
const primaryColor = 'var(--sd-primary, var(--ts-chart-1, #171717))'
const mutedColor = 'var(--sd-muted-foreground, #737373)'
const borderColor = 'var(--sd-border, #e5e5e5)'
const dashboardTooltip: ChartTooltipOptions<DashboardAreaDatum> = {
className: 'sd-chart-tooltip',
anchor: 'group-center',
placement: ['top', 'right', 'left', 'bottom'],
sticky: false,
sort: 'color-domain',
content(points): ChartTooltipContent {
return {
title: formatDashboardDate(points[0]?.xValue ?? ''),
rows: points.map((point) => ({
label: point.datum.series === 'desktop' ? 'Desktop' : 'Mobile',
value: point.datum.visitors.toLocaleString('en-US'),
color: primaryColor,
})),
}
},
}
export function createExampleChart(
data: readonly DashboardDatum[],
width = 288,
) {
const rows = dashboardAreaRows(data)
const tickValues = dashboardTickValues(data, width)
return defineChart(
{
marks: [
areaY(rows, {
id: 'visitor-areas',
x: 'dateKey',
y: 'visitors',
z: 'series',
color: 'series',
key: (row) => `${row.dateKey}:${row.series}`,
layout: stack({ order: seriesOrder }),
curve: d3Curve(curveNatural),
fill: (row) => `url(#fill-${row.series})`,
fillOpacity: 0.6,
stroke: primaryColor,
strokeWidth: 1,
}),
],
scales: {
x: {
scale: scalePoint,
axis: {
line: false,
ticks: {
values: tickValues,
size: 0,
padding: 8,
format: formatDashboardDate,
},
tickLabels: {
fontSize: 12,
opacity: 0.72,
dy: 5,
anchor: ({ value }) =>
value === data.at(-1)?.date ? 'end' : 'middle',
dx: ({ value }) => (value === data.at(-1)?.date ? 5 : 0),
thin: { minGap: 32, priority: 'ends' },
},
},
},
y: {
scale: scaleLinear().domain([0, 1_200]),
grid: true,
axis: {
line: false,
ticks: { values: [0, 300, 600, 900, 1_200], size: 0 },
tickLabels: false,
},
},
},
color: { domain: seriesOrder, range: [primaryColor, primaryColor] },
gradients: [
{
id: 'fill-mobile',
x1: 0,
y1: 1,
x2: 0,
y2: 0,
stops: [
{ offset: 0.05, color: primaryColor, opacity: 0.1 },
{ offset: 0.95, color: primaryColor, opacity: 0.8 },
],
},
{
id: 'fill-desktop',
x1: 0,
y1: 1,
x2: 0,
y2: 0,
stops: [
{ offset: 0.05, color: primaryColor, opacity: 0.1 },
{ offset: 0.95, color: primaryColor, opacity: 1 },
],
},
],
margin: { top: 5, right: 5, bottom: 35, left: 5 },
theme: {
foreground: mutedColor,
grid: borderColor,
background: 'transparent',
palette: [primaryColor],
},
clip: true,
},
{
svgAnimation: false,
focus: 'group-x',
focusRing: true,
maxFocusDistance: Number.POSITIVE_INFINITY,
keyboard: true,
tooltip: { use: tooltip, ...dashboardTooltip },
},
)
}
export const definition = createExampleChart(filterDashboardData('90d'), 288)
function DashboardChart({ data, input }: DashboardChartProps) {
const width = dashboardChartWidth(input.width)
const renderer = useMemo(
() =>
motion({
initial: 'always',
transition: {
type: 'spring',
stiffness: 170,
damping: 18,
mass: 1,
},
}),
[],
)
const chartDefinition = useMemo(
() => createExampleChart(data, width),
[data, width],
)
return (
<Chart
definition={chartDefinition}
renderer={renderer}
initialWidth={width}
height={250}
ariaLabel="Total visitors for the last three months"
/>
)
}
export interface ExampleProps {
width?: number
height?: number
}
export default function Example({ width = 1024, height = 768 }: ExampleProps) {
return (
<ShadcnDashboard ChartRenderer={DashboardChart} input={{ width, height }} />
)
}cases/127-shadcn-dashboard/styles.ts807 lines · dependency
cases/127-shadcn-dashboard/styles.ts
export const shadcnDashboardStyles = `
.shadcn-dashboard {
--sd-background: oklch(1 0 0);
--sd-foreground: oklch(0.145 0 0);
--sd-card: oklch(1 0 0);
--sd-card-foreground: oklch(0.145 0 0);
--sd-primary: oklch(0.205 0 0);
--sd-primary-foreground: oklch(0.985 0 0);
--sd-secondary: oklch(0.97 0 0);
--sd-muted: oklch(0.97 0 0);
--sd-muted-foreground: oklch(0.556 0 0);
--sd-accent: oklch(0.97 0 0);
--sd-border: oklch(0.922 0 0);
--sd-input: oklch(0.922 0 0);
--sd-sidebar: oklch(0.985 0 0);
--sd-sidebar-accent: oklch(0.97 0 0);
--sd-radius: 0.625rem;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--sd-foreground);
background: var(--sd-background);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
text-rendering: geometricPrecision;
}
:root[data-theme='dark'] .shadcn-dashboard {
--sd-background: oklch(0.145 0 0);
--sd-foreground: oklch(0.985 0 0);
--sd-card: oklch(0.205 0 0);
--sd-card-foreground: oklch(0.985 0 0);
--sd-primary: oklch(0.922 0 0);
--sd-primary-foreground: oklch(0.205 0 0);
--sd-secondary: oklch(0.269 0 0);
--sd-muted: oklch(0.269 0 0);
--sd-muted-foreground: oklch(0.708 0 0);
--sd-accent: oklch(0.269 0 0);
--sd-border: oklch(1 0 0 / 10%);
--sd-input: oklch(1 0 0 / 15%);
--sd-sidebar: oklch(0.205 0 0);
--sd-sidebar-accent: oklch(0.269 0 0);
}
.shadcn-dashboard,
.shadcn-dashboard * {
box-sizing: border-box;
}
.shadcn-dashboard :where(button, select, input) {
min-height: 0;
color: inherit;
font: inherit;
}
.sd-viewport {
display: flex;
width: 100%;
height: 100%;
overflow: auto;
background: var(--sd-sidebar);
}
.sd-sidebar {
position: sticky;
top: 0;
display: flex;
flex: 0 0 256px;
height: 100%;
flex-direction: column;
border-right: 1px solid var(--sd-border);
background: var(--sd-sidebar);
}
.sd-sidebar-inner {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--sd-sidebar);
}
.sd-brand,
.sd-user {
display: flex;
min-height: 48px;
align-items: center;
gap: 10px;
padding: 8px;
}
.sd-brand {
min-height: 49px;
border-bottom: 1px solid var(--sd-border);
}
.sd-brand-mark {
width: 20px;
height: 20px;
color: var(--sd-foreground);
}
.sd-brand-name {
overflow: hidden;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
text-overflow: ellipsis;
white-space: nowrap;
}
.sd-nav {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 18px;
overflow: auto;
padding: 4px 8px 12px;
}
.sd-nav-group {
display: grid;
gap: 2px;
}
.sd-nav-label {
padding: 6px 8px 3px;
color: var(--sd-muted-foreground);
font-size: 12px;
font-weight: 500;
}
.sd-nav-bottom {
margin-top: auto;
}
.sd-nav-item {
display: flex;
width: 100%;
height: 32px;
align-items: center;
gap: 9px;
padding: 0 8px;
border: 0;
border-radius: 6px;
color: var(--sd-foreground);
background: transparent;
cursor: default;
text-align: left;
}
.sd-nav-item:hover,
.sd-nav-item[data-active='true'] {
background: var(--sd-sidebar-accent);
}
.sd-nav-item svg,
.sd-user svg,
.sd-header svg,
.sd-toolbar svg,
.sd-badge svg {
width: 16px;
height: 16px;
flex: none;
}
.sd-nav-more {
margin-left: auto;
color: var(--sd-muted-foreground);
}
.sd-user {
border-top: 1px solid transparent;
}
.sd-avatar {
display: grid;
width: 32px;
height: 32px;
flex: none;
place-items: center;
border-radius: 8px;
color: var(--sd-primary-foreground);
background: var(--sd-primary);
font-size: 11px;
font-weight: 650;
}
.sd-user-copy {
min-width: 0;
}
.sd-user-copy strong,
.sd-user-copy span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sd-user-copy strong {
font-size: 13px;
font-weight: 600;
}
.sd-user-copy span {
color: var(--sd-muted-foreground);
font-size: 11px;
}
.sd-main {
min-width: 0;
flex: 1;
margin: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--sd-background);
}
.sd-main-scroll {
width: 100%;
height: 100%;
overflow: auto;
}
.sd-header {
position: sticky;
top: 0;
z-index: 4;
display: flex;
height: 49px;
align-items: center;
gap: 10px;
padding: 0 24px;
border-bottom: 1px solid var(--sd-border);
background: color-mix(in srgb, var(--sd-background) 94%, transparent);
backdrop-filter: blur(8px);
}
.sd-icon-button {
display: inline-grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
}
.sd-separator {
display: none;
width: 1px;
height: 16px;
margin: 0 6px;
background: var(--sd-border);
}
.sd-menu-button {
display: none;
}
.sd-header h1 {
margin: 0;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.01em;
}
.sd-quick-create {
display: inline-flex;
height: 32px;
align-items: center;
gap: 6px;
margin-left: auto;
padding: 0 11px;
border: 0;
border-radius: 6px;
color: var(--sd-primary-foreground);
background: var(--sd-primary);
font-size: 13px;
font-weight: 600;
}
.shadcn-dashboard .sd-quick-create {
color: var(--sd-primary-foreground);
}
.sd-content {
container-type: inline-size;
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
}
.sd-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.sd-card {
position: relative;
overflow: hidden;
border: 1px solid var(--sd-border);
border-radius: var(--sd-radius);
color: var(--sd-card-foreground);
background:
linear-gradient(to top, color-mix(in srgb, var(--sd-primary) 5%, transparent), transparent 45%),
var(--sd-card);
box-shadow: 0 1px 2px rgb(0 0 0 / 3%);
}
:root[data-theme='dark'] .sd-card {
background: var(--sd-card);
}
.sd-stat-card {
min-height: 200px;
padding: 23px 23px 20px;
}
.sd-stat-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 3px 12px;
}
.sd-stat-label,
.sd-chart-description {
color: var(--sd-muted-foreground);
font-size: 14px;
}
.sd-stat-value {
grid-column: 1;
margin: 0;
font-size: clamp(24px, 2.35cqw, 30px);
font-weight: 600;
letter-spacing: -0.035em;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.sd-badge {
display: inline-flex;
height: 23px;
align-items: center;
gap: 4px;
padding: 0 7px;
border: 1px solid var(--sd-border);
border-radius: 7px;
color: var(--sd-foreground);
background: transparent;
font-size: 12px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.sd-stat-foot {
display: grid;
gap: 3px;
margin-top: 30px;
font-size: 14px;
}
.sd-stat-trend {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.sd-stat-trend svg {
width: 16px;
height: 16px;
flex: none;
}
.sd-stat-detail {
overflow: hidden;
color: var(--sd-muted-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.sd-chart-card {
height: 392px;
background: var(--sd-card);
}
.sd-chart-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 16px;
align-items: start;
padding: 24px 24px 0;
}
.sd-chart-title {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
}
.sd-chart-description {
grid-column: 1;
}
.sd-range-buttons {
grid-column: 2;
grid-row: 1 / span 2;
display: inline-flex;
height: 32px;
overflow: hidden;
border: 1px solid var(--sd-border);
border-radius: 8px;
}
.sd-range-button {
height: 30px;
padding: 0 16px;
border: 0;
border-left: 1px solid var(--sd-border);
background: transparent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.sd-range-button:first-child {
border-left: 0;
}
.sd-range-button:hover,
.sd-range-button[data-active='true'] {
background: var(--sd-accent);
}
.sd-range-select {
display: none;
grid-column: 2;
grid-row: 1 / span 2;
width: 160px;
height: 32px;
padding: 0 30px 0 10px;
border: 1px solid var(--sd-border);
border-radius: 8px;
background: var(--sd-background);
font-size: 13px;
}
.sd-chart-stage {
position: relative;
width: 100%;
height: 274px;
margin-top: 24px;
padding: 8px 24px 0;
}
.sd-chart-stage > * {
width: 100%;
height: 100%;
}
.sd-chart-stage .ts-chart-host {
color: var(--sd-muted-foreground);
font-size: 12px;
}
.sd-chart-stage .ts-chart__grid line {
stroke: var(--sd-border);
}
.sd-chart-stage .ts-chart__axis text {
fill: var(--sd-muted-foreground);
}
.ts-chart-tooltip.sd-chart-tooltip {
min-width: 138px !important;
padding: 8px 10px !important;
border: 1px solid var(--sd-border) !important;
border-radius: 8px !important;
color: var(--sd-card-foreground) !important;
background: var(--sd-card) !important;
box-shadow: 0 2px 6px rgb(0 0 0 / 8%) !important;
font: 12px/1.45 Inter, ui-sans-serif, system-ui, sans-serif !important;
}
.ts-chart-tooltip.sd-chart-tooltip .ts-chart-tooltip__title {
margin-bottom: 5px !important;
font-weight: 500 !important;
}
.ts-chart-tooltip.sd-chart-tooltip .ts-chart-tooltip__row {
grid-template-columns: 8px minmax(0, 1fr) auto !important;
column-gap: 7px !important;
}
.ts-chart-tooltip.sd-chart-tooltip .ts-chart-tooltip__swatch {
width: 8px !important;
height: 8px !important;
border-radius: 999px !important;
}
.sd-recharts-tooltip {
min-width: 138px;
padding: 8px 10px;
border: 1px solid var(--sd-border);
border-radius: 8px;
color: var(--sd-card-foreground);
background: var(--sd-card);
box-shadow: 0 2px 6px rgb(0 0 0 / 8%);
font-size: 12px;
line-height: 1.45;
}
.sd-recharts-tooltip-date {
margin-bottom: 5px;
font-weight: 500;
}
.sd-recharts-tooltip-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
}
.sd-recharts-tooltip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--sd-primary);
}
.sd-table-section {
display: grid;
gap: 24px;
}
.sd-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sd-tabs {
display: inline-flex;
height: 36px;
align-items: center;
padding: 3px;
border-radius: 9px;
background: var(--sd-muted);
}
.sd-tab {
height: 30px;
padding: 0 11px;
border: 0;
border-radius: 7px;
background: transparent;
font-size: 13px;
}
.sd-tab[data-active='true'] {
border: 1px solid var(--sd-border);
background: var(--sd-background);
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
}
.sd-toolbar-actions {
display: flex;
gap: 8px;
}
.sd-button {
display: inline-flex;
height: 32px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 11px;
border: 1px solid var(--sd-border);
border-radius: 8px;
background: var(--sd-background);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.sd-table-wrap {
overflow: hidden;
border: 1px solid var(--sd-border);
border-radius: 9px;
}
.sd-table-scroll {
overflow-x: auto;
}
.sd-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
font-size: 13px;
}
.sd-table th,
.sd-table td {
height: 48px;
padding: 0 12px;
border-bottom: 1px solid var(--sd-border);
text-align: left;
white-space: nowrap;
}
.sd-table th {
height: 40px;
color: var(--sd-muted-foreground);
background: var(--sd-muted);
font-weight: 500;
}
.sd-table tbody tr:last-child td {
border-bottom: 0;
}
.sd-grip {
color: var(--sd-muted-foreground);
letter-spacing: -3px;
}
.sd-checkbox {
width: 16px;
height: 16px;
accent-color: var(--sd-primary);
}
.sd-table-link {
color: var(--sd-foreground);
font-weight: 500;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--sd-muted-foreground) 45%, transparent);
text-underline-offset: 4px;
}
.sd-status {
display: inline-flex;
align-items: center;
gap: 5px;
}
.sd-status-dot {
width: 8px;
height: 8px;
border: 1.5px solid currentColor;
border-radius: 999px;
}
.sd-status[data-done='true'] .sd-status-dot {
border-color: #22c55e;
background: #22c55e;
box-shadow: inset 0 0 0 1.5px var(--sd-card);
}
.sd-number-input {
width: 64px;
height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
text-align: right;
}
.sd-number-input:hover {
background: color-mix(in srgb, var(--sd-input) 30%, transparent);
}
.sd-table-footer {
display: flex;
min-height: 36px;
align-items: center;
gap: 24px;
color: var(--sd-muted-foreground);
font-size: 13px;
}
.sd-pagination {
display: flex;
margin-left: auto;
align-items: center;
gap: 8px;
color: var(--sd-foreground);
font-weight: 500;
}
@container (min-width: 1000px) {
.sd-cards {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@container (max-width: 540px) {
.sd-cards {
grid-template-columns: 1fr;
}
.sd-stat-card {
min-height: 132px;
}
.sd-tabs {
display: none;
}
.sd-toolbar::before {
content: 'Outline';
display: inline-flex;
height: 32px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--sd-border);
border-radius: 8px;
font-size: 13px;
}
}
@container (max-width: 767px) {
.sd-range-buttons {
display: none;
}
.sd-range-select {
display: block;
}
}
@media (max-width: 767px) {
.sd-sidebar {
display: none;
}
.sd-main {
margin: 0;
border: 0;
border-radius: 0;
}
.sd-header,
.sd-content {
padding-inline: 16px;
}
.sd-menu-button {
display: inline-grid;
}
.sd-separator {
display: block;
}
.sd-quick-create {
display: none;
}
}
@media (max-width: 480px) {
.sd-chart-card {
height: 414px;
}
.sd-chart-header {
grid-template-columns: 1fr;
}
.sd-range-select {
grid-column: 1;
grid-row: 3;
margin-top: 8px;
}
.sd-chart-stage {
padding-inline: 8px;
}
.sd-button span {
display: none;
}
}
`