Skip to content

Commit 5ef7b3c

Browse files
committed
fix(blocks): make navigation context aware
1 parent 97af41f commit 5ef7b3c

3 files changed

Lines changed: 124 additions & 29 deletions

File tree

apps/blocks/src/components/site/site-sidebar.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ export function SiteSidebar({ open, onNavigate, className }: SiteSidebarProps) {
165165
onToggle={() => setFoundationsOpen((v) => !v)}
166166
>
167167
<div className="flex flex-col gap-0.5">
168+
<NavLink href="/" active={pathname === '/'} onNavigate={onNavigate}>
169+
Overview
170+
</NavLink>
168171
<NavLink href="/blocks" active={pathname === '/blocks'} onNavigate={onNavigate}>
169172
Setup
170173
</NavLink>
@@ -175,9 +178,6 @@ export function SiteSidebar({ open, onNavigate, className }: SiteSidebarProps) {
175178
>
176179
Styling
177180
</NavLink>
178-
<NavLink href="/" active={pathname === '/'} onNavigate={onNavigate}>
179-
Overview
180-
</NavLink>
181181
</div>
182182
</NavSection>
183183

@@ -204,10 +204,9 @@ export function SiteSidebar({ open, onNavigate, className }: SiteSidebarProps) {
204204
</div>
205205
</nav>
206206

207-
<div className="flex items-center gap-2 border-t border-sidebar-border px-4 py-3 text-[11.5px] text-muted-foreground">
208-
<span className="inline-block size-[7px] rounded-full bg-success" aria-hidden />
209-
{BASE_PRIMITIVES.length} base primitives · @constructive
210-
</div>
207+
<footer className="px-4 pb-4 pt-2 text-[11.5px] text-muted-foreground">
208+
Built by <span className="font-medium text-sidebar-foreground">Constructive</span>
209+
</footer>
211210
</aside>
212211
);
213212
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
vi.mock('next/navigation', () => ({
5+
usePathname: vi.fn(() => '/'),
6+
}));
7+
8+
vi.mock('@/components/site/theme-toggle', () => ({
9+
ThemeToggle: () => <button type="button">Theme</button>,
10+
}));
11+
12+
import { usePathname } from 'next/navigation';
13+
14+
import { SiteTopbar } from './site-topbar';
15+
16+
const mockUsePathname = vi.mocked(usePathname);
17+
let writeText: ReturnType<typeof vi.fn>;
18+
19+
beforeEach(() => {
20+
mockUsePathname.mockReturnValue('/');
21+
writeText = vi.fn().mockResolvedValue(undefined);
22+
Object.defineProperty(navigator, 'clipboard', {
23+
configurable: true,
24+
value: { writeText },
25+
});
26+
});
27+
28+
afterEach(() => {
29+
cleanup();
30+
vi.clearAllMocks();
31+
});
32+
33+
describe('SiteTopbar install command', () => {
34+
it('copies the exact registry item for the current component reference', async () => {
35+
mockUsePathname.mockReturnValue('/blocks/ui/breadcrumb');
36+
render(<SiteTopbar />);
37+
38+
const copyButton = screen.getByRole('button', { name: 'Copy Breadcrumb install command' });
39+
expect(copyButton).toHaveTextContent('shadcn add @constructive/breadcrumb');
40+
41+
fireEvent.click(copyButton);
42+
43+
await waitFor(() => {
44+
expect(writeText).toHaveBeenCalledWith('pnpm dlx shadcn@4.13.1 add @constructive/breadcrumb');
45+
expect(copyButton).toHaveAccessibleName('Breadcrumb install command copied');
46+
});
47+
});
48+
49+
it('normalizes trailing slashes before resolving the registry item', () => {
50+
mockUsePathname.mockReturnValue('/blocks/ui/dialog/');
51+
render(<SiteTopbar />);
52+
53+
expect(screen.getByRole('button', { name: 'Copy Dialog install command' })).toHaveTextContent(
54+
'shadcn add @constructive/dialog',
55+
);
56+
});
57+
58+
it.each(['/', '/blocks', '/blocks/styling', '/blocks/ui/not-a-registry-item'])(
59+
'hides the install command on %s',
60+
(pathname) => {
61+
mockUsePathname.mockReturnValue(pathname);
62+
render(<SiteTopbar />);
63+
64+
expect(screen.queryByRole('button', { name: /install command/i })).not.toBeInTheDocument();
65+
expect(screen.queryByText(/shadcn add @constructive\//)).not.toBeInTheDocument();
66+
},
67+
);
68+
});

apps/blocks/src/components/site/site-topbar.tsx

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Check, Menu, Terminal } from 'lucide-react';
88
import { Button } from '@constructive-io/ui/button';
99

1010
import { ThemeToggle } from '@/components/site/theme-toggle';
11-
import { getBasePrimitive } from '@/lib/base-primitives';
11+
import { getBasePrimitive, registryInstall } from '@/lib/base-primitives';
1212

1313
function normalizePath(path: string) {
1414
if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1);
@@ -27,7 +27,20 @@ function crumbFor(path: string): string {
2727
return 'Registry';
2828
}
2929

30-
const CLI = 'pnpm dlx shadcn@4.13.1 add @constructive/button';
30+
function installActionFor(path: string) {
31+
const normalizedPath = normalizePath(path);
32+
if (!normalizedPath.startsWith('/blocks/ui/')) return null;
33+
34+
const name = normalizedPath.slice('/blocks/ui/'.length);
35+
const primitive = getBasePrimitive(name);
36+
if (!primitive) return null;
37+
38+
return {
39+
command: registryInstall(primitive),
40+
label: `shadcn add @constructive/${primitive.name}`,
41+
title: primitive.title,
42+
};
43+
}
3144

3245
type SiteTopbarProps = {
3346
onMenuClick?: () => void;
@@ -36,15 +49,22 @@ type SiteTopbarProps = {
3649
export function SiteTopbar({ onMenuClick }: SiteTopbarProps) {
3750
const pathname = usePathname() ?? '';
3851
const crumb = crumbFor(pathname);
39-
const [copied, setCopied] = useState(false);
52+
const installAction = installActionFor(pathname);
53+
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
54+
const copied = installAction?.command === copiedCommand;
4055

4156
async function copyCli() {
57+
if (!installAction) return;
58+
59+
const { command } = installAction;
4260
try {
43-
await navigator.clipboard.writeText(CLI);
44-
setCopied(true);
45-
window.setTimeout(() => setCopied(false), 1600);
61+
await navigator.clipboard.writeText(command);
62+
setCopiedCommand(command);
63+
window.setTimeout(() => {
64+
setCopiedCommand((current) => (current === command ? null : current));
65+
}, 1600);
4666
} catch {
47-
setCopied(false);
67+
setCopiedCommand((current) => (current === command ? null : current));
4868
}
4969
}
5070

@@ -59,7 +79,7 @@ export function SiteTopbar({ onMenuClick }: SiteTopbarProps) {
5979
aria-label="Open navigation"
6080
onClick={onMenuClick}
6181
>
62-
<Menu className="size-4" />
82+
<Menu aria-hidden />
6383
</Button>
6484

6585
<nav
@@ -80,20 +100,28 @@ export function SiteTopbar({ onMenuClick }: SiteTopbarProps) {
80100

81101
<ThemeToggle />
82102

83-
<Button
84-
type="button"
85-
variant="secondary"
86-
size="sm"
87-
className="hidden sm:inline-flex"
88-
onClick={copyCli}
89-
>
90-
{copied ? (
91-
<Check className="size-3.5 text-emerald-500" />
92-
) : (
93-
<Terminal className="size-3.5" />
94-
)}
95-
<span className="font-mono text-xs">{copied ? 'Copied' : 'npx shadcn add'}</span>
96-
</Button>
103+
{installAction ? (
104+
<Button
105+
type="button"
106+
variant="secondary"
107+
size="sm"
108+
className="hidden sm:inline-flex"
109+
onClick={copyCli}
110+
aria-label={
111+
copied
112+
? `${installAction.title} install command copied`
113+
: `Copy ${installAction.title} install command`
114+
}
115+
title={installAction.command}
116+
>
117+
{copied ? (
118+
<Check data-icon="inline-start" className="text-emerald-500" />
119+
) : (
120+
<Terminal data-icon="inline-start" />
121+
)}
122+
<span className="font-mono text-xs">{copied ? 'Copied' : installAction.label}</span>
123+
</Button>
124+
) : null}
97125

98126
<Button asChild size="sm">
99127
<Link href="/blocks">Setup</Link>

0 commit comments

Comments
 (0)