Skip to content

Commit 34577ee

Browse files
m-hulbertclaude
andcommitted
refactor(nav): flatten left sidebar and move language selector
Flatten the left sidebar to show a single product's nav with static section headings and accordion sub-menus. Move the language selector from the left sidebar to the right sidebar, restyle it as a bordered button matching the secondary button style. Add useShowLanguageSelector hook to determine visibility based on page languages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e24e69f commit 34577ee

7 files changed

Lines changed: 208 additions & 247 deletions

File tree

src/components/Layout/LanguageSelector.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('LanguageSelector', () => {
7272

7373
it('renders the LanguageSelector component with default language (JS)', () => {
7474
render(<LanguageSelector />);
75-
expect(screen.getByText('icon-gui-chevron-down-micro')).toBeInTheDocument();
75+
expect(screen.getByText('icon-gui-chevron-down-solid')).toBeInTheDocument();
7676
expect(screen.getByText('icon-tech-javascript')).toBeInTheDocument();
7777
expect(screen.getByText('JavaScript')).toBeInTheDocument();
7878
});
@@ -83,7 +83,7 @@ describe('LanguageSelector', () => {
8383
fireEvent.click(trigger);
8484

8585
await waitFor(() => {
86-
expect(screen.getByText('Code Language')).toBeInTheDocument();
86+
expect(screen.getByText('Python')).toBeInTheDocument();
8787
});
8888
});
8989

@@ -106,13 +106,13 @@ describe('LanguageSelector', () => {
106106
fireEvent.click(trigger);
107107

108108
await waitFor(() => {
109-
expect(screen.getByText('Code Language')).toBeInTheDocument();
109+
expect(screen.getByText('Python')).toBeInTheDocument();
110110
});
111111

112112
fireEvent.keyDown(trigger, { key: 'Escape', code: 'Escape' });
113113

114114
await waitFor(() => {
115-
expect(screen.queryByText('Code Language')).not.toBeInTheDocument();
115+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
116116
});
117117
});
118118

src/components/Layout/LanguageSelector.tsx

Lines changed: 28 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LanguageKey } from 'src/data/languages/types';
1111
import { useLayoutContext } from 'src/contexts/layout-context';
1212
import { navigate } from '../Link';
1313
import { LANGUAGE_SELECTOR_HEIGHT, INKEEP_ASK_BUTTON_HEIGHT } from './utils/heights';
14+
import { secondaryButtonClassName } from './utils/styles';
1415
import * as Select from '../ui/Select';
1516
import { Skeleton } from '../ui/Skeleton';
1617

@@ -69,36 +70,25 @@ const SingleLanguageSelector = () => {
6970
const selectedLang = languageInfo[selectedOption.label];
7071

7172
return (
72-
<div
73-
className="flex items-center md:relative w-full text-right md:text-left"
74-
style={{ height: LANGUAGE_SELECTOR_HEIGHT }}
75-
>
73+
<div className="flex items-center md:relative w-full">
7674
<Select.Root value={value} onValueChange={handleValueChange}>
77-
<Select.Trigger
78-
className={cn(
79-
'border-none inline-flex items-center group/lang-dropdown focus-base rounded px-0',
80-
options.length > 1 ? 'cursor-pointer' : 'cursor-auto',
81-
)}
82-
style={{ height: LANGUAGE_SELECTOR_HEIGHT }}
83-
aria-label="Select code language"
84-
disabled={options.length === 1}
85-
>
86-
<div className="ui-text-label4 text-left leading-none w-full text-neutral-1100 dark:text-neutral-200 hover:text-neutral-1200 dark:hover:text-neutral-300 flex gap-2 items-center">
75+
<Select.Trigger asChild>
76+
<button
77+
className={cn(secondaryButtonClassName, 'gap-1.5', options.length > 1 ? 'cursor-pointer' : 'cursor-auto')}
78+
aria-label="Select code language"
79+
disabled={options.length === 1}
80+
>
8781
<Icon size="20px" name={`icon-tech-${selectedLang?.alias ?? selectedOption.label}` as IconName} />
88-
<span className="text-neutral-900 dark:text-neutral-400 font-semibold">{selectedLang?.label}</span>
82+
<span className="font-semibold">{selectedLang?.label}</span>
8983
<Badge color="neutral" size="xs" className="my-px">
9084
v{selectedOption.version}
9185
</Badge>
92-
</div>
93-
{options.length > 1 && (
94-
<Select.Icon className="flex items-center pl-2 text-red-orange cursor-pointer">
95-
<Icon
96-
name="icon-gui-chevron-down-micro"
97-
size="20px"
98-
additionalCSS="text-neutral-700 group-hover/lang-dropdown:text-neutral-1300 dark:text-neutral-600 dark:group-hover/lang-dropdown:text-neutral-000 transition-colors"
99-
/>
100-
</Select.Icon>
101-
)}
86+
{options.length > 1 && (
87+
<Select.Icon className="flex items-center">
88+
<Icon name="icon-gui-chevron-down-solid" size="12px" />
89+
</Select.Icon>
90+
)}
91+
</button>
10292
</Select.Trigger>
10393

10494
<Select.Portal>
@@ -119,9 +109,6 @@ const SingleLanguageSelector = () => {
119109
),
120110
}}
121111
>
122-
<p className="ui-text-overline2 text-left p-2 pb-3 text-neutral-700 dark:text-neutral-600">
123-
Code Language
124-
</p>
125112
{options.map((option) => {
126113
const lang = languageInfo[option.label];
127114
return (
@@ -229,31 +216,23 @@ const DualLanguageDropdown = ({ label, paramName, languages, selectedLanguage }:
229216
<div className="flex items-center gap-2">
230217
<span className="text-p4 font-semibold text-neutral-900 dark:text-neutral-400 whitespace-nowrap">{label}</span>
231218
<Select.Root value={value} onValueChange={handleValueChange}>
232-
<Select.Trigger
233-
className={cn(
234-
'border-none inline-flex items-center group/lang-dropdown focus-base rounded px-0',
235-
options.length > 1 ? 'cursor-pointer' : 'cursor-auto',
236-
)}
237-
style={{ height: LANGUAGE_SELECTOR_HEIGHT }}
238-
aria-label={`Select ${label.toLowerCase()} language`}
239-
disabled={options.length === 1}
240-
>
241-
<div className="ui-text-label4 text-left leading-none text-neutral-1100 dark:text-neutral-200 hover:text-neutral-1200 dark:hover:text-neutral-300 flex gap-2 items-center">
219+
<Select.Trigger asChild>
220+
<button
221+
className={cn(secondaryButtonClassName, 'gap-1.5', options.length > 1 ? 'cursor-pointer' : 'cursor-auto')}
222+
aria-label={`Select ${label.toLowerCase()} language`}
223+
disabled={options.length === 1}
224+
>
242225
<Icon size="20px" name={`icon-tech-${selectedLang?.alias ?? selectedOption.label}` as IconName} />
243-
<span className="text-neutral-900 dark:text-neutral-400 font-semibold">{selectedLang?.label}</span>
226+
<span className="font-semibold">{selectedLang?.label}</span>
244227
<Badge color="neutral" size="xs" className="my-px">
245228
v{selectedOption.version}
246229
</Badge>
247-
</div>
248-
{options.length > 1 && (
249-
<Select.Icon className="flex items-center pl-2 text-red-orange cursor-pointer">
250-
<Icon
251-
name="icon-gui-chevron-down-micro"
252-
size="20px"
253-
additionalCSS="text-neutral-700 group-hover/lang-dropdown:text-neutral-1300 dark:text-neutral-600 dark:group-hover/lang-dropdown:text-neutral-000 transition-colors"
254-
/>
255-
</Select.Icon>
256-
)}
230+
{options.length > 1 && (
231+
<Select.Icon className="flex items-center">
232+
<Icon name="icon-gui-chevron-down-solid" size="12px" />
233+
</Select.Icon>
234+
)}
235+
</button>
257236
</Select.Trigger>
258237

259238
<Select.Portal>
Lines changed: 41 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from 'react';
22
import { useLocation } from '@reach/router';
3-
import { render, screen, waitFor } from '@testing-library/react';
4-
import userEvent from '@testing-library/user-event';
3+
import { render, screen } from '@testing-library/react';
54
import LeftSidebar from './LeftSidebar';
65
import { useLayoutContext } from 'src/contexts/layout-context';
76

@@ -14,6 +13,7 @@ jest.mock('src/contexts/layout-context', () => ({
1413
language: 'javascript',
1514
product: null,
1615
template: null,
16+
hasProductBar: false,
1717
},
1818
}),
1919
}));
@@ -37,10 +37,14 @@ jest.mock('gatsby', () => ({
3737
}));
3838

3939
jest.mock('../Link', () => {
40-
const MockLink: React.FC<{ children: React.ReactNode; to: string }> = ({ children, to, ...props }) => (
41-
<a href={to} {...props}>
42-
{children}
43-
</a>
40+
// eslint-disable-next-line @typescript-eslint/no-var-requires
41+
const { forwardRef } = require('react');
42+
const MockLink = forwardRef(
43+
({ children, to, ...props }: { children: React.ReactNode; to: string }, ref: React.Ref<HTMLAnchorElement>) => (
44+
<a href={to} ref={ref} {...props}>
45+
{children}
46+
</a>
47+
),
4448
);
4549
MockLink.displayName = 'MockLink';
4650
return MockLink;
@@ -53,20 +57,22 @@ describe('LeftSidebar', () => {
5357
beforeEach(() => {
5458
mockUseLayoutContext.mockReturnValue({
5559
activePage: {
56-
page: { name: 'Introduction', link: '/platform/intro' },
60+
page: { name: 'About Ably', link: '/docs/platform' },
5761
tree: [
5862
{ index: 0, page: { name: 'Platform', link: '/platform' } },
59-
{ index: 0, page: { name: 'Introduction', link: '/platform/intro' } },
63+
{ index: 0, page: { name: 'About Ably', link: '/docs/platform' } },
6064
],
6165
languages: [],
6266
language: 'javascript',
6367
product: 'platform',
6468
template: null,
69+
hasProductBar: false,
6570
},
6671
});
6772

6873
mockUseLocation.mockReturnValue({
69-
pathname: '/platform/intro',
74+
pathname: '/docs/platform',
75+
search: '',
7076
});
7177

7278
const mockIntersectionObserver = jest.fn();
@@ -84,98 +90,40 @@ describe('LeftSidebar', () => {
8490
jest.clearAllMocks();
8591
});
8692

87-
it('renders the sidebar with products', () => {
93+
it('renders nav content for the active product only', () => {
8894
render(<LeftSidebar />);
89-
expect(screen.getByRole('button', { name: 'Platform' })).toBeInTheDocument();
90-
expect(screen.getByRole('button', { name: 'Ably Pub/Sub' })).toBeInTheDocument();
91-
});
92-
93-
it('shows Platform accordion expanded with first three child items when active page is under Platform', async () => {
94-
render(<LeftSidebar />);
95-
96-
// Platform should be auto-expanded since active page is under Platform (index 0)
97-
await waitFor(() => {
98-
expect(screen.getByText('About Ably')).toBeInTheDocument();
99-
expect(screen.getByText('Architecture')).toBeInTheDocument();
100-
expect(screen.getByText('Products and SDKs')).toBeInTheDocument();
101-
});
102-
103-
// Verify these are clickable accordion triggers
104-
const aboutAblyButton = screen.getByText('About Ably').closest('button');
105-
const archButton = screen.getByText('Architecture').closest('button');
106-
const productsButton = screen.getByText('Products and SDKs').closest('button');
95+
// Platform nav sections should be present
96+
expect(screen.getByText('About Ably')).toBeInTheDocument();
97+
expect(screen.getByText('Architecture')).toBeInTheDocument();
10798

108-
expect(aboutAblyButton).toBeInTheDocument();
109-
expect(archButton).toBeInTheDocument();
110-
expect(productsButton).toBeInTheDocument();
99+
// Other products should NOT appear
100+
expect(screen.queryByText('Ably Pub/Sub')).not.toBeInTheDocument();
101+
expect(screen.queryByText('Ably Chat')).not.toBeInTheDocument();
111102
});
112103

113-
it('expands Platform/Architecture accordion and shows first three child items', async () => {
114-
const user = userEvent.setup();
115-
render(<LeftSidebar />);
116-
117-
// Platform should be auto-expanded since active page is under Platform (index 0)
118-
await waitFor(() => {
119-
expect(screen.getByText('Architecture')).toBeInTheDocument();
120-
});
121-
122-
// Architecture children should not be visible yet
123-
expect(screen.queryByText('Overview')).not.toBeInTheDocument();
124-
expect(screen.queryByText('Edge network')).not.toBeInTheDocument();
125-
expect(screen.queryByText('Infrastructure operations')).not.toBeInTheDocument();
126-
127-
// Find and click Architecture button to expand it
128-
const architectureButton = screen.getByText('Architecture').closest('button');
129-
if (!architectureButton) {
130-
throw new Error('Architecture button not found');
131-
}
132-
await user.click(architectureButton);
133-
134-
// After clicking, verify the first three child items are visible
135-
await waitFor(() => {
136-
expect(screen.getByText('Overview')).toBeInTheDocument();
137-
expect(screen.getByText('Edge network')).toBeInTheDocument();
138-
expect(screen.getByText('Infrastructure operations')).toBeInTheDocument();
104+
it('shows placeholder when no product is active', () => {
105+
mockUseLayoutContext.mockReturnValue({
106+
activePage: {
107+
page: { name: '', link: '' },
108+
tree: [],
109+
languages: [],
110+
language: 'javascript',
111+
product: null,
112+
template: null,
113+
hasProductBar: false,
114+
},
139115
});
140116

141-
// Verify these are links (leaf nodes) not accordion triggers
142-
const overviewLink = screen.getByText('Overview').closest('a');
143-
const edgeNetworkLink = screen.getByText('Edge network').closest('a');
144-
const infrastructureLink = screen.getByText('Infrastructure operations').closest('a');
145-
146-
expect(overviewLink).toBeInTheDocument();
147-
expect(edgeNetworkLink).toBeInTheDocument();
148-
expect(infrastructureLink).toBeInTheDocument();
117+
render(<LeftSidebar />);
118+
expect(screen.getByText('Select a product above to browse documentation.')).toBeInTheDocument();
149119
});
150120

151-
it('clicks Ably Pub/Sub to expand Pub/Sub showing first three child items', async () => {
152-
const user = userEvent.setup();
121+
it('renders top-level sections as static headings', () => {
153122
render(<LeftSidebar />);
154-
155-
// Platform should be auto-expanded since active page is under Platform (index 0)
156-
await waitFor(() => {
157-
expect(screen.getByText('Architecture')).toBeInTheDocument();
158-
});
159-
160-
// Pub/Sub children should not be visible initially
161-
expect(screen.queryByText('About Pub/Sub')).not.toBeInTheDocument();
162-
expect(screen.queryByText('Getting started')).not.toBeInTheDocument();
163-
164-
// Click on Ably Pub/Sub button to expand it (type="multiple" so Platform stays open)
165-
const pubsubButton = screen.getByRole('button', { name: 'Ably Pub/Sub' });
166-
await user.click(pubsubButton);
167-
168-
// After clicking, verify the Pub/Sub child accordion items appear
169-
await waitFor(() => {
170-
expect(screen.getByText('About Pub/Sub')).toBeInTheDocument();
171-
expect(screen.getByText('Getting started')).toBeInTheDocument();
172-
});
173-
174-
// Verify both product sections are visible (Platform has "About Ably", Pub/Sub has "About Pub/Sub")
175-
expect(screen.getByText('About Ably')).toBeInTheDocument();
176-
expect(screen.getByText('About Pub/Sub')).toBeInTheDocument();
177-
178-
// Platform's Architecture should still be visible since accordion type is "multiple"
179-
expect(screen.getByText('Architecture')).toBeInTheDocument();
123+
// Top-level sections render as plain headings, not accordion triggers
124+
const sectionHeading = screen.getByText('Getting started');
125+
expect(sectionHeading).toBeInTheDocument();
126+
// Should not be inside an accordion trigger button
127+
expect(sectionHeading.closest('button')).toBeNull();
180128
});
181129
});

0 commit comments

Comments
 (0)