Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion apps/website/src/components/docs/DocsControlPlane.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocsControlPlane, DocsContextContent } from './DocsControlPlane';
import { docsConfig } from '../../lib/docs-config';

const LIBRARY_TITLES = docsConfig.filter((l) => l.group === 'library').map((l) => l.title);

const push = vi.fn();
vi.mock('next/navigation', () => ({
Expand Down Expand Up @@ -93,6 +96,113 @@ describe('DocsControlPlane', () => {
expect(document.getElementById(controlledId)).toBeTruthy();
});

it('stacks the adapter title and tagline on separate lines', () => {
render(
<DocsControlPlane
activeLibrary="langgraph"
activeSection="guides"
activeSlug="streaming"
pageTitle="Streaming"
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'LangGraph' }));
const item = screen.getByRole('menuitemradio', { name: /LangGraph/ });
const title = item.querySelector('.docs-sidebar-lib-item-title');
const tagline = item.querySelector('.docs-sidebar-lib-item-desc');
if (!title || !tagline) throw new Error('Expected a title and a tagline');

// The collision regression: both spans rendered inline on one line, so the
// row read as "LangGraphTalk to LangGraph directly".
expect(title.textContent).toBe('LangGraph');
expect(tagline.textContent).toBe('Talk to LangGraph directly');
const wrapper = title.parentElement;
if (!wrapper) throw new Error('Expected a text wrapper');
expect(wrapper.className).toContain('docs-sidebar-lib-item-text');
});

it('splits the menu into labelled adapter and library groups', () => {
render(
<DocsControlPlane
activeLibrary="langgraph"
activeSection="guides"
activeSlug="streaming"
pageTitle="Streaming"
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'LangGraph' }));
const adapters = screen.getByRole('group', { name: 'Adapters' });
const libraries = screen.getByRole('group', { name: 'Libraries' });

// Adapters are asserted exactly: there are two, and only they carry
// taglines. A new library must not quietly land in this group.
expect(within(adapters).getAllByRole('menuitemradio').map((i) => i.textContent)).toEqual([
'LangGraphTalk to LangGraph directly',
'AG-UIAny AG-UI backend',
]);
// Libraries are checked against config so adding one does not churn this
// test — misclassifying one still fails the assertion above.
expect(within(libraries).getAllByRole('menuitemradio').map((i) => i.textContent)).toEqual(
LIBRARY_TITLES,
);
});

it('renders menu entries as real links with the current one checked', () => {
render(
<DocsControlPlane
activeLibrary="langgraph"
activeSection="guides"
activeSlug="streaming"
pageTitle="Streaming"
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'LangGraph' }));
const agUi = screen.getByRole('menuitemradio', { name: /AG-UI/ });
expect(agUi.tagName).toBe('A');
expect(agUi.getAttribute('href')).toBe('/docs/ag-ui/getting-started/introduction');
expect(agUi.getAttribute('aria-checked')).toBe('false');
expect(
screen.getByRole('menuitemradio', { name: /LangGraph/ }).getAttribute('aria-checked'),
).toBe('true');
});

it('drops the library row from environment now the picker owns it', () => {
render(
<DocsControlPlane
activeLibrary="langgraph"
activeSection="guides"
activeSlug="streaming"
pageTitle="Streaming"
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'Environment' }));
expect(screen.getByText('Angular')).toBeTruthy();
expect(screen.getByText('npm')).toBeTruthy();
expect(screen.queryByText('Library')).toBeNull();
});

it('caps the open menu to the space left below the trigger', () => {
render(
<DocsControlPlane
activeLibrary="langgraph"
activeSection="guides"
activeSlug="streaming"
pageTitle="Streaming"
/>,
);

fireEvent.click(screen.getByRole('button', { name: 'LangGraph' }));
const menu = screen.getByRole('menu');

// A viewport-percentage cap cannot work: the menu opens ~342px down the
// pane, so `60vh` still overflows a short window by ~100px. The cap has to
// be measured from the trigger's own position.
expect(menu.style.maxHeight).toMatch(/^\d+(\.\d+)?px$/);
});

it('supports keyboard entry and dismissal for the library menu', () => {
render(
<DocsControlPlane
Expand All @@ -105,7 +215,15 @@ describe('DocsControlPlane', () => {

const trigger = screen.getByRole('button', { name: 'LangGraph' });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
const firstItem = screen.getByRole('menuitem', { name: /LangGraph/ });
const firstItem = screen.getByRole('menuitemradio', { name: /LangGraph/ });
expect(document.activeElement).toBe(firstItem);

// Traversal must still cross the Adapters/Libraries group boundary.
fireEvent.keyDown(firstItem, { key: 'End' });
expect(document.activeElement).toBe(
screen.getByRole('menuitemradio', { name: LIBRARY_TITLES[LIBRARY_TITLES.length - 1] }),
);
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' });
expect(document.activeElement).toBe(firstItem);

fireEvent.keyDown(firstItem, { key: 'Escape' });
Expand Down
2 changes: 0 additions & 2 deletions apps/website/src/components/docs/DocsControlPlane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Braces,
Code2,
ExternalLink,
Package,
Play,
Search,
} from 'lucide-react';
Expand Down Expand Up @@ -58,7 +57,6 @@ export function DocsContextContent({
window.setTimeout(dispatchSearch, 0);
};
const environmentRows = [
{ label: 'Library', value: library?.title ?? activeLibrary, icon: <Package size={15} aria-hidden="true" /> },
{ label: 'Framework', value: 'Angular', icon: <Blocks size={15} aria-hidden="true" /> },
{ label: 'Package manager', value: 'npm', icon: <Code2 size={15} aria-hidden="true" /> },
];
Expand Down
112 changes: 81 additions & 31 deletions apps/website/src/components/docs/DocsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type KeyboardEvent,
} from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { usePathname } from 'next/navigation';
import {
Blocks,
BookOpen,
Expand All @@ -24,8 +24,10 @@ import {
import {
docsConfig,
getLibraryConfig,
libraryIntroPath,
specialDocsPages,
type DocsSection,
type LibraryGroup,
type LibraryId,
} from '../../lib/docs-config';
import { LibraryMark } from './LibraryMark';
Expand All @@ -39,6 +41,23 @@ export interface DocsNavigationProps {
onNavigate?: () => void;
}

/**
* Menu entries are `menuitemradio` (single-select from a set), not `menuitem`.
* Keyboard traversal reads this — if it drifts from the rendered role, arrow
* keys and Escape silently stop finding anything.
*/
const MENU_ITEM_SELECTOR = '[role="menuitemradio"]';

/** Breathing room between the open menu and the bottom of the viewport. */
const MENU_VIEWPORT_GUTTER = 16;
/** Below this the menu is uselessly short; let the pane scroll instead. */
const MENU_MIN_HEIGHT = 180;

const LIBRARY_GROUPS: { id: LibraryGroup; label: string }[] = [
{ id: 'adapter', label: 'Adapters' },
{ id: 'library', label: 'Libraries' },
];

function LibraryDropdown({
activeLibrary,
onNavigate,
Expand All @@ -52,7 +71,6 @@ function LibraryDropdown({
const menuRef = useRef<HTMLDivElement>(null);
const initialFocusRef = useRef(0);
const menuId = useId();
const router = useRouter();

useEffect(() => {
const handler = (event: MouseEvent) => {
Expand All @@ -64,10 +82,30 @@ function LibraryDropdown({

useEffect(() => {
if (!open) return;
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]');
const items = menuRef.current?.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR);
items?.[initialFocusRef.current]?.focus();
}, [open]);

/**
* Cap the menu to the room actually left below the trigger. A CSS `vh` cap
* cannot do this: the menu opens ~340px down the pane, so even `60vh` still
* overflows a short window by ~100px. Measured from the trigger, it never
* runs past the fold — it scrolls internally instead.
*/
useEffect(() => {
if (!open) return;
const resize = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const available = window.innerHeight - trigger.getBoundingClientRect().bottom - MENU_VIEWPORT_GUTTER;
menu.style.maxHeight = `${Math.max(MENU_MIN_HEIGHT, available)}px`;
};
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, [open]);

const openMenu = (initialIndex: number) => {
initialFocusRef.current = initialIndex;
setOpen(true);
Expand All @@ -80,9 +118,9 @@ function LibraryDropdown({

const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
menuRef.current?.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR) ?? [],
);
const current = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement));
const current = Math.max(0, items.indexOf(document.activeElement as HTMLElement));
const nextIndex =
event.key === 'Home'
? 0
Expand Down Expand Up @@ -145,33 +183,45 @@ function LibraryDropdown({
className="docs-sidebar-lib-menu"
onKeyDown={onMenuKeyDown}
>
{docsConfig.map((library) => (
<button
type="button"
role="menuitem"
tabIndex={-1}
key={library.id}
onClick={() => {
closeMenu();
onNavigate?.();
router.push(`/docs/${library.id}/getting-started/introduction`);
}}
className="docs-sidebar-lib-item"
data-active={library.id === activeLibrary || undefined}
>
<span className="docs-sidebar-lib-item-icon">
<LibraryMark library={library.id} size={20} />
</span>
<span className="docs-sidebar-lib-item-text">
<span
className="docs-sidebar-lib-item-title"
data-active={library.id === activeLibrary || undefined}
>
{library.title}
</span>
<span className="docs-sidebar-lib-item-desc">{library.description}</span>
{LIBRARY_GROUPS.map((group, groupIndex) => (
<div role="group" aria-label={group.label} key={group.id}>
{groupIndex > 0 ? <span className="docs-sidebar-lib-divider" aria-hidden="true" /> : null}
<span className="docs-sidebar-lib-group" aria-hidden="true">
{group.label}
</span>
</button>
{docsConfig
.filter((library) => library.group === group.id)
.map((library) => {
const isActive = library.id === activeLibrary;
return (
<Link
role="menuitemradio"
aria-checked={isActive}
tabIndex={-1}
key={library.id}
href={libraryIntroPath(library.id)}
onClick={() => {
closeMenu();
onNavigate?.();
}}
className="docs-sidebar-lib-item"
data-active={isActive || undefined}
>
<span className="docs-sidebar-lib-item-icon">
<LibraryMark library={library.id} size={20} />
</span>
<span className="docs-sidebar-lib-item-text">
<span className="docs-sidebar-lib-item-title" data-active={isActive || undefined}>
{library.title}
</span>
{library.tagline ? (
<span className="docs-sidebar-lib-item-desc">{library.tagline}</span>
) : null}
</span>
</Link>
);
})}
</div>
))}
</div>
) : null}
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/components/shared/Nav.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('Docs mobile navigation', () => {
const libraryTrigger = within(dialog).getByRole('button', { name: 'LangGraph' });
fireEvent.click(libraryTrigger);

fireEvent.keyDown(within(dialog).getByRole('menuitem', { name: /LangGraph/ }), {
fireEvent.keyDown(within(dialog).getByRole('menuitemradio', { name: /LangGraph/ }), {
key: 'Escape',
});

Expand Down
Loading
Loading