Skip to content

Commit 7dbb610

Browse files
authored
Merge pull request #3303 from ably/docs-nav-update
Update docs navigation to horizontal
2 parents 48087b7 + 7996f43 commit 7dbb610

37 files changed

Lines changed: 1420 additions & 1026 deletions

data/onCreatePage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type LayoutOptions = {
1212
const mdxWrapper = path.resolve('src/components/Layout/MDXWrapper.tsx');
1313

1414
const pageLayoutOptions: Record<string, LayoutOptions> = {
15-
'/docs': { leftSidebar: true, rightSidebar: false, template: 'index', mdx: false },
15+
'/docs': { leftSidebar: false, rightSidebar: false, template: 'index', mdx: false },
1616
'/docs/api/control-api': {
1717
leftSidebar: false,
1818
rightSidebar: false,

data/onPostBuild/generateMarkdownFooter.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ describe('generateMarkdownFooter', () => {
7777
expect(setupPage?.sectionKey).toBe('Ably Chat::Concepts');
7878
});
7979

80-
it('should use __root__ section key for top-level pages', () => {
80+
it('should use section key for pages nested in a section', () => {
8181
const pages = flattenNavPages();
8282
const aboutChat = pages.find((p) => p.link === '/docs/chat');
83-
expect(aboutChat?.sectionKey).toBe('Ably Chat::__root__');
83+
expect(aboutChat?.sectionKey).toBe('Ably Chat::Getting started');
8484
});
8585

8686
it('should handle nested sections correctly', () => {

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@
4949
"@radix-ui/react-accordion": "^1.2.12",
5050
"@radix-ui/react-dropdown-menu": "^2.1.16",
5151
"@radix-ui/react-select": "^2.2.6",
52+
"@radix-ui/react-separator": "^1.1.8",
5253
"@radix-ui/react-tabs": "^1.1.13",
5354
"@radix-ui/react-tooltip": "^1.2.8",
5455
"@react-hook/media-query": "^1.1.1",
5556
"@sentry/gatsby": "^9.19.0",
5657
"@types/cheerio": "^1.0.0",
5758
"@types/prop-types": "^15.7.4",
5859
"cheerio": "^1.0.0-rc.10",
60+
"class-variance-authority": "^0.7.1",
5961
"dompurify": "^3.4.0",
6062
"fast-glob": "^3.3.3",
6163
"front-matter": "^4.0.2",

src/components/Examples/ExamplesContent.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('ExamplesContent', () => {
5858

5959
it('filters examples based on search input (no results)', () => {
6060
render(<ExamplesContent exampleImages={exampleImages} />);
61-
const searchInput = screen.getByPlaceholderText('Find an example');
61+
const searchInput = screen.getByPlaceholderText('Find an example...');
6262
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
6363
expect(screen.getByText('No results found')).toBeInTheDocument();
6464
expect(screen.queryByText('Member location')).not.toBeInTheDocument();
@@ -67,7 +67,7 @@ describe('ExamplesContent', () => {
6767

6868
it('filters examples based on search input (with results)', () => {
6969
render(<ExamplesContent exampleImages={exampleImages} />);
70-
const searchInput = screen.getByPlaceholderText('Find an example');
70+
const searchInput = screen.getByPlaceholderText('Find an example...');
7171
fireEvent.change(searchInput, { target: { value: 'avatar' } });
7272
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
7373
expect(screen.queryByText('Member location')).not.toBeInTheDocument();

src/components/Examples/ExamplesFilter.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import ReactDOM from 'react-dom';
33
import Icon from '@ably/ui/core/Icon';
4+
import { Input } from 'src/components/ui/Input';
45
import { products } from '../../data/examples';
56
import Button from '@ably/ui/core/Button';
67
import cn from '@ably/ui/core/utils/cn';
@@ -111,18 +112,22 @@ const ExamplesFilter = ({
111112

112113
return (
113114
<>
114-
<div className="h-[2.125rem] sm:h-[1.875rem] w-5 absolute left-2 top-1 flex items-center justify-center select-none cursor-default">
115-
<Icon name={'icon-gui-magnifying-glass-outline'} size="1rem" />
115+
<div className="relative w-full">
116+
<Icon
117+
name="icon-gui-magnifying-glass-outline"
118+
size="16px"
119+
additionalCSS="absolute left-3 top-1/2 -translate-y-1/2 z-10 text-neutral-600 dark:text-neutral-700 pointer-events-none"
120+
/>
121+
<Input
122+
type="search"
123+
className="rounded bg-neutral-100 dark:bg-neutral-1200 pl-9 w-full h-10 sm:h-[2.125rem]"
124+
placeholder="Find an example..."
125+
autoComplete="off"
126+
aria-label="Search examples"
127+
role="searchbox"
128+
onChange={(e) => handleSearch(e)}
129+
/>
116130
</div>
117-
<input
118-
type="search"
119-
className="ui-input pl-9 w-full h-10 sm:h-[2.125rem] ui-text-p3"
120-
placeholder="Find an example"
121-
autoComplete="off"
122-
aria-label="Search examples"
123-
role="searchbox"
124-
onChange={(e) => handleSearch(e)}
125-
/>
126131
<Button
127132
className="flex sm:hidden mt-4 w-full"
128133
variant="secondary"

src/components/Layout/Breadcrumbs.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,25 +70,25 @@ describe('Breadcrumbs', () => {
7070
render(<Breadcrumbs />);
7171

7272
// Current page (last item) should be disabled
73-
expect(screen.getByText('Current Page')).toHaveClass('text-gui-unavailable');
73+
expect(screen.getByText('Current Page')).toHaveClass('text-neutral-700');
7474
expect(screen.getByText('Current Page')).toHaveClass('pointer-events-none');
7575

7676
// Non-linked nodes (link='#') should be disabled
77-
expect(screen.getByText('Subsection 1')).toHaveClass('text-gui-unavailable');
77+
expect(screen.getByText('Subsection 1')).toHaveClass('text-neutral-700');
7878
expect(screen.getByText('Subsection 1')).toHaveClass('pointer-events-none');
7979

8080
// Active links should not be disabled
81-
expect(screen.getByText('Section 1')).not.toHaveClass('text-gui-unavailable');
81+
expect(screen.getByText('Section 1')).not.toHaveClass('text-neutral-700');
8282
expect(screen.getByText('Section 1')).not.toHaveClass('pointer-events-none');
8383
});
8484

8585
it('shows only the last active node in mobile view', () => {
8686
render(<Breadcrumbs />);
8787

88-
// All items except index 0 should have 'hidden sm:flex' classes
88+
// All items except index 0 should have 'hidden md:flex' classes
8989
expect(screen.getByText('Section 1')).not.toHaveClass('hidden');
90-
expect(screen.getByText('Subsection 1')).toHaveClass('hidden', 'sm:flex');
91-
expect(screen.getByText('Current Page')).toHaveClass('hidden', 'sm:flex');
90+
expect(screen.getByText('Subsection 1')).toHaveClass('hidden', 'md:flex');
91+
expect(screen.getByText('Current Page')).toHaveClass('hidden', 'md:flex');
9292
});
9393

9494
it('correctly identifies last active node when current page is non-linked', () => {
@@ -108,8 +108,8 @@ describe('Breadcrumbs', () => {
108108
});
109109

110110
render(<Breadcrumbs />);
111-
expect(screen.getByText('Section 1')).toHaveClass('hidden', 'sm:flex');
111+
expect(screen.getByText('Section 1')).toHaveClass('hidden', 'md:flex');
112112
expect(screen.getByText('Subsection 1')).not.toHaveClass('hidden');
113-
expect(screen.getByText('Current Page')).toHaveClass('hidden', 'sm:flex');
113+
expect(screen.getByText('Current Page')).toHaveClass('hidden', 'md:flex');
114114
});
115115
});

src/components/Layout/Breadcrumbs.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import cn from '@ably/ui/core/utils/cn';
66
import { hierarchicalKey } from './utils/nav';
77

88
const linkStyles =
9-
'ui-text-label4 font-semibold text-neutral-900 hover:text-neutral-1300 active:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-000 dark:active:text-neutral-500 focus-base transition-colors';
9+
'ui-text-label4 font-semibold text-neutral-900 hover:text-neutral-1300 active:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-000 dark:active:text-neutral-500 focus-base transition-colors whitespace-nowrap';
1010

1111
const Breadcrumbs: React.FC = () => {
1212
const { activePage } = useLayoutContext();
@@ -35,40 +35,40 @@ const Breadcrumbs: React.FC = () => {
3535
})();
3636

3737
return (
38-
<nav aria-label="breadcrumb" className="flex mt-8 items-center gap-1">
38+
<nav aria-label="breadcrumb" className="flex items-center gap-1 min-w-0 flex-wrap">
3939
{lastActiveNodeIndex === null && (
4040
<Icon
41-
name="icon-gui-chevron-left-micro"
42-
size="16px"
41+
name="icon-gui-chevron-left-solid"
42+
size="12px"
4343
color="text-neutral-900 dark:text-neutral-400"
44-
additionalCSS="sm:hidden"
44+
additionalCSS="md:hidden shrink-0"
4545
/>
4646
)}
47-
<Link to="/docs" className={cn(linkStyles, lastActiveNodeIndex !== null && 'hidden sm:block')}>
47+
<Link to="/docs" className={cn(linkStyles, lastActiveNodeIndex !== null && 'hidden md:block')}>
4848
Home
4949
</Link>
5050
<Icon
51-
name="icon-gui-chevron-right-micro"
52-
size="16px"
51+
name="icon-gui-chevron-right-solid"
52+
size="12px"
5353
color="text-neutral-900 dark:text-neutral-400"
54-
additionalCSS={cn('rotate-180 sm:rotate-0', { 'hidden sm:flex': lastActiveNodeIndex === null })}
54+
additionalCSS={cn('shrink-0 rotate-180 md:rotate-0', { 'hidden md:flex': lastActiveNodeIndex === null })}
5555
/>
5656
{activePage.tree.map((node, index) => (
5757
<React.Fragment key={hierarchicalKey(node.page.link, index, activePage.tree)}>
5858
{index > 0 ? (
5959
<Icon
60-
name="icon-gui-chevron-right-micro"
61-
size="16px"
60+
name="icon-gui-chevron-right-solid"
61+
size="12px"
6262
color="text-neutral-900 dark:text-neutral-400"
63-
additionalCSS="hidden sm:flex"
63+
additionalCSS="hidden md:flex shrink-0"
6464
/>
6565
) : null}
6666
<Link
6767
to={node.page.link}
6868
className={cn(linkStyles, {
69-
'text-gui-unavailable dark:text-gui-unavailable-dark pointer-events-none':
69+
'text-neutral-700 dark:text-neutral-700 pointer-events-none':
7070
index === activePage.tree.length - 1 || node.page.link === '#',
71-
'hidden sm:flex': index !== lastActiveNodeIndex,
71+
'hidden md:flex': index !== lastActiveNodeIndex,
7272
})}
7373
>
7474
{node.page.name}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import React, { ReactNode } from 'react';
2+
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
3+
import CopyForLLM from './CopyForLLM';
4+
5+
const mockUseLayoutContext = jest.fn(() => ({
6+
activePage: {
7+
language: 'javascript',
8+
languages: ['javascript'],
9+
product: 'pubsub',
10+
page: {
11+
name: 'Test Page',
12+
link: '/docs/test-page',
13+
},
14+
tree: [],
15+
template: 'mdx' as const,
16+
},
17+
}));
18+
19+
jest.mock('src/contexts/layout-context', () => ({
20+
useLayoutContext: () => mockUseLayoutContext(),
21+
}));
22+
23+
jest.mock('@reach/router', () => ({
24+
useLocation: () => ({ pathname: '/docs/test-page' }),
25+
}));
26+
27+
jest.mock('@ably/ui/core/Icon', () => ({
28+
__esModule: true,
29+
default: ({ name }: { name: string }) => <span data-testid={`icon-${name}`}>{name}</span>,
30+
}));
31+
32+
jest.mock('@ably/ui/core/insights', () => ({
33+
track: jest.fn(),
34+
}));
35+
36+
// Mock Radix DropdownMenu to render content directly
37+
jest.mock('@radix-ui/react-dropdown-menu', () => ({
38+
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
39+
Trigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <div>{children}</div>,
40+
Portal: ({ children }: { children: ReactNode }) => <div>{children}</div>,
41+
Content: ({ children }: { children: ReactNode }) => <div>{children}</div>,
42+
Item: ({
43+
children,
44+
onSelect,
45+
...props
46+
}: {
47+
children: ReactNode;
48+
onSelect?: (e: Event) => void;
49+
asChild?: boolean;
50+
}) =>
51+
props.asChild ? (
52+
<>{children}</>
53+
) : (
54+
<div onClick={() => onSelect?.({ preventDefault: () => undefined } as unknown as Event)}>{children}</div>
55+
),
56+
Separator: () => <hr />,
57+
}));
58+
59+
describe('CopyForLLM', () => {
60+
beforeEach(() => {
61+
jest.clearAllMocks();
62+
});
63+
64+
afterEach(() => {
65+
jest.useRealTimers();
66+
jest.restoreAllMocks();
67+
jest.clearAllTimers();
68+
});
69+
70+
it('renders the dropdown trigger button', () => {
71+
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));
72+
73+
render(<CopyForLLM />);
74+
expect(screen.getByText('Copy for LLM')).toBeInTheDocument();
75+
});
76+
77+
it('renders LLM links for ChatGPT and Claude', () => {
78+
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));
79+
80+
render(<CopyForLLM />);
81+
expect(screen.getByText('Open in ChatGPT')).toBeInTheDocument();
82+
expect(screen.getByText('Open in Claude')).toBeInTheDocument();
83+
});
84+
85+
it('shows markdown items when content is available', async () => {
86+
const mockMarkdown = '# Test content';
87+
88+
global.fetch = jest.fn(() =>
89+
Promise.resolve({
90+
ok: true,
91+
headers: {
92+
get: (name: string) => (name === 'Content-Type' ? 'text/markdown' : null),
93+
},
94+
text: () => Promise.resolve(mockMarkdown),
95+
} as Response),
96+
);
97+
98+
const mockWriteText = jest.fn();
99+
Object.assign(navigator, { clipboard: { writeText: mockWriteText } });
100+
101+
render(<CopyForLLM />);
102+
103+
// Wait for the markdown to be fetched and state to update (button becomes enabled)
104+
const copyButton = await screen.findByText('Copy for LLM');
105+
await waitFor(() => {
106+
expect(copyButton.closest('button')).not.toBeDisabled();
107+
});
108+
109+
jest.useFakeTimers();
110+
111+
// Click copy via the dropdown item
112+
const copyItem = screen.getByText('Copy as markdown').closest('div');
113+
if (copyItem) {
114+
act(() => {
115+
fireEvent.click(copyItem);
116+
});
117+
}
118+
119+
expect(mockWriteText).toHaveBeenCalledWith(mockMarkdown);
120+
121+
act(() => {
122+
jest.runOnlyPendingTimers();
123+
});
124+
jest.useRealTimers();
125+
});
126+
127+
it('disables copy button when fetch fails', async () => {
128+
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));
129+
130+
render(<CopyForLLM />);
131+
132+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
133+
134+
const copyButton = screen.getByText('Copy for LLM').closest('button');
135+
expect(copyButton).toBeDisabled();
136+
});
137+
});

0 commit comments

Comments
 (0)