Add performance optimizations and accessibility improvements
Dynamic imports for heavy components (Kanban, Gantt, CommandPalette), skip-to-content link, ARIA landmarks/labels on sidebar, breadcrumbs, topbar notifications, kanban board, gantt timeline, and pipeline progress. Focus-visible ring styles for keyboard navigation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8691f1a1c2
commit
f4e6da9210
16 changed files with 132 additions and 18 deletions
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
@ -4915,6 +4916,23 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.19",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.19.tgz",
|
||||
"integrity": "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.19"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
|
|
@ -4928,6 +4946,16 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.19",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz",
|
||||
"integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Suspense } from "react";
|
|||
import { Sidebar, MobileSidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { LazyCommandPalette } from "@/components/lazy-command-palette";
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
@ -11,13 +11,13 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-auto p-4 md:p-6">
|
||||
<main id="main-content" className="flex-1 overflow-auto p-4 md:p-6">
|
||||
<Suspense>{children}</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<MobileSidebar />
|
||||
<CommandPalette />
|
||||
<LazyCommandPalette />
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDeliverables, useUpdateStageStatus } from "@/hooks/use-deliverables";
|
||||
import { KanbanBoard } from "@/components/views/kanban-board";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const KanbanBoard = dynamic(
|
||||
() => import("@/components/views/kanban-board").then((m) => m.KanbanBoard),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function BoardViewPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { data: deliverables, isLoading } = useDeliverables(projectId);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDeliverables } from "@/hooks/use-deliverables";
|
||||
import { GanttTimeline } from "@/components/views/gantt-timeline";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const GanttTimeline = dynamic(
|
||||
() => import("@/components/views/gantt-timeline").then((m) => m.GanttTimeline),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function TimelineViewPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { data: deliverables, isLoading } = useDeliverables(projectId);
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ export default function GlobalError({
|
|||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="flex min-h-screen items-center justify-center p-6" role="alert">
|
||||
<div className="max-w-md text-center">
|
||||
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-[var(--accent)]" />
|
||||
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-[var(--accent)]" aria-hidden="true" />
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
Something went wrong
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -114,4 +114,27 @@
|
|||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Focus-visible ring for keyboard navigation */
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-[var(--ring)];
|
||||
}
|
||||
|
||||
/* Remove default focus outlines for mouse users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Screen-reader only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ export default function RootLayout({
|
|||
suppressHydrationWarning
|
||||
>
|
||||
<body>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="fixed left-2 top-2 z-[100] -translate-y-16 rounded-md bg-[var(--primary)] px-4 py-2 text-sm font-medium text-[var(--primary-foreground)] transition-transform focus:translate-y-0"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default function NotFound() {
|
|||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<FileQuestion className="mx-auto mb-4 h-12 w-12 text-[var(--muted-foreground)]" />
|
||||
<FileQuestion className="mx-auto mb-4 h-12 w-12 text-[var(--muted-foreground)]" aria-hidden="true" />
|
||||
<h1 className="font-heading text-4xl font-bold">404</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted-foreground)]">
|
||||
The page you're looking for doesn't exist.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,14 @@ export function PipelineProgress({ stages }: { stages: Stage[] }) {
|
|||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={sorted.length}
|
||||
aria-label={`Pipeline progress: ${completed} of ${sorted.length} stages complete`}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
{sorted.map((stage) => (
|
||||
<Tooltip key={stage.id} delayDuration={0}>
|
||||
|
|
|
|||
|
|
@ -32,18 +32,19 @@ export function Breadcrumbs() {
|
|||
});
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
|
||||
aria-label="Home"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
{crumbs.map((crumb) => (
|
||||
<Fragment key={crumb.href}>
|
||||
<ChevronRight className="h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
<ChevronRight className="h-3 w-3 text-[var(--muted-foreground)]" aria-hidden="true" />
|
||||
{crumb.isLast ? (
|
||||
<span className="font-medium text-[var(--foreground)]">
|
||||
<span className="font-medium text-[var(--foreground)]" aria-current="page">
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function NavLinks({
|
|||
|
||||
return (
|
||||
<>
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
<nav className="flex-1 space-y-1 p-2" aria-label="Main navigation">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
|
|
@ -79,7 +79,7 @@ function NavLinks({
|
|||
|
||||
<Separator />
|
||||
|
||||
<nav className="space-y-1 p-2">
|
||||
<nav className="space-y-1 p-2" aria-label="Settings">
|
||||
{bottomItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
|
|
@ -127,6 +127,8 @@ export function Sidebar() {
|
|||
"hidden h-screen flex-col border-r bg-[var(--muted)] transition-all duration-200 md:flex",
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Sidebar"
|
||||
>
|
||||
{/* Logo / Title */}
|
||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function Topbar() {
|
|||
const items = (notifications as any[]) ?? [];
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)] px-4 md:px-6">
|
||||
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)] px-4 md:px-6" role="banner">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileMenuButton />
|
||||
<Breadcrumbs />
|
||||
|
|
@ -63,16 +63,29 @@ export function Topbar() {
|
|||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative h-8 w-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-8 w-8"
|
||||
aria-label={
|
||||
unreadCount > 0
|
||||
? `Notifications, ${unreadCount} unread`
|
||||
: "Notifications"
|
||||
}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -right-1 -top-1 h-4 min-w-4 rounded-full p-0 text-[10px] leading-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{unreadCount > 0 ? `${unreadCount} unread notifications` : ""}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
|
|
|
|||
12
src/components/lazy-command-palette.tsx
Normal file
12
src/components/lazy-command-palette.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const CommandPalette = dynamic(
|
||||
() => import("@/components/command-palette").then((m) => m.CommandPalette),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export function LazyCommandPalette() {
|
||||
return <CommandPalette />;
|
||||
}
|
||||
|
|
@ -131,7 +131,11 @@ export function GanttTimeline({
|
|||
const chartWidth = totalDays * DAY_WIDTH;
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-md border">
|
||||
<div
|
||||
className="overflow-auto rounded-md border"
|
||||
role="region"
|
||||
aria-label="Gantt timeline chart"
|
||||
>
|
||||
<div className="flex" style={{ minWidth: LABEL_WIDTH + chartWidth }}>
|
||||
{/* Labels column */}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -105,7 +105,11 @@ export function KanbanBoard({
|
|||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-4"
|
||||
role="region"
|
||||
aria-label="Kanban board"
|
||||
>
|
||||
{COLUMNS.map((column) => {
|
||||
const items = grouped.get(column.id) ?? [];
|
||||
|
||||
|
|
@ -113,12 +117,15 @@ export function KanbanBoard({
|
|||
<div
|
||||
key={column.id}
|
||||
className="w-72 shrink-0 rounded-lg bg-[var(--muted)] p-3"
|
||||
role="region"
|
||||
aria-label={`${column.label} column, ${items.length} item${items.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: column.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-semibold">{column.label}</span>
|
||||
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue