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
34 changes: 23 additions & 11 deletions src/components/Layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import Link from '../Link';
import { InkeepSearchBar } from '../SearchBar/InkeepSearchBar';
import { secondaryButtonClassName, iconButtonClassName, tooltipContentClassName } from './utils/styles';
import { useLayoutContext } from 'src/contexts/layout-context';
import { ProductKey } from 'src/data/types';

// Tailwind 'md' breakpoint from tailwind.config.js
const MD_BREAKPOINT = 1040;
const CLI_ENABLED = false;
const MAX_MOBILE_MENU_WIDTH = '560px';

const headerLinkClassName = 'px-4 py-1.5 rounded-lg ui-text-label3 font-semibold transition-colors';
const headerLinkClassName =
'inline-flex items-center h-9 px-4 rounded-lg ui-text-label3 font-semibold transition-colors';
const activeHeaderLinkClassName = 'text-neutral-1300 dark:text-neutral-000 bg-orange-100 dark:bg-orange-1000';
const inactiveHeaderLinkClassName =
'text-neutral-900 dark:text-neutral-500 hover:text-neutral-1300 dark:hover:text-neutral-000 hover:bg-neutral-100 dark:hover:bg-neutral-1200';
Expand Down Expand Up @@ -80,6 +82,8 @@ const Header: React.FC = () => {
account: userContext.sessionState.account ?? { links: { dashboard: { href: '#' } } },
};
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Which product's TOC the mobile "Products" tab shows. null until the user picks one.
const [mobileProduct, setMobileProduct] = useState<ProductKey | null>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const burgerButtonRef = useRef<HTMLDivElement>(null);
const searchBarRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -122,6 +126,8 @@ const Header: React.FC = () => {

useEffect(() => {
setIsMobileMenuOpen(false);
// Reset the mobile product selection so reopening the menu reflects the current page.
setMobileProduct(null);
}, [activePage]);

const handleLogout = useCallback(async () => {
Expand All @@ -148,6 +154,16 @@ const Header: React.FC = () => {
}
}, [sessionState.logOut]);

// The mobile "Products" tab shows the user's picked product, falling back to the current
// page's product (ignoring 'platform', which has its own tab). null renders the
// "select a product" placeholder.
const currentProduct = activePage.product && activePage.product !== 'platform' ? activePage.product : null;
const productsTabProduct = mobileProduct ?? currentProduct;

// Open the burger menu on the tab matching the current page: the Products tab (with this
// product's TOC) when viewing product docs, Examples on examples pages, else Platform.
const defaultMobileTabIndex = currentProduct ? 1 : location.pathname.includes('/examples') ? 2 : 0;

return (
<div className="fixed top-0 w-full z-50 bg-neutral-000 dark:bg-neutral-1300 border-b border-neutral-300 dark:border-neutral-1000">
<div className="flex items-center justify-between h-16 px-5 max-w-[1600px] mx-auto">
Expand Down Expand Up @@ -201,22 +217,18 @@ const Header: React.FC = () => {
tabs={mobileTabs}
contents={[
<div key="nav-mobile-platform-tab">
<LeftSidebar inHeader />
<LeftSidebar inHeader product="platform" />
</div>,
<div key="nav-mobile-products-tab" className="flex flex-col h-full overflow-hidden">
<div className="shrink-0">
<ProductBar />
</div>
<div className="flex-1 overflow-y-auto">
<LeftSidebar inHeader />
</div>
<div key="nav-mobile-products-tab">
<ProductBar onSelectProduct={setMobileProduct} selectedProduct={productsTabProduct} />
<LeftSidebar inHeader product={productsTabProduct} />
</div>,
<ExamplesList key="nav-mobile-examples-tab" />,
]}
rootClassName="h-full overflow-y-hidden min-h-[3.1875rem] flex flex-col"
contentClassName="h-full overflow-y-scroll"
contentClassName="flex-1 min-h-0 overflow-y-auto"
tabClassName="ui-text-menu2 !px-4"
options={{ flexibleTabWidth: true }}
options={{ flexibleTabWidth: true, defaultTabIndex: defaultMobileTabIndex }}
/>
</div>
)}
Expand Down
19 changes: 19 additions & 0 deletions src/components/Layout/LanguageSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ describe('LanguageSelector', () => {
expect(screen.getByText('Python')).toBeInTheDocument();
});

it('renders a static label without a dropdown when only one language is available', () => {
mockUseLayoutContext.mockReturnValue({
activePage: {
tree: [0],
languages: ['javascript'],
language: 'javascript',
},
products: [['pubsub']],
});

render(<LanguageSelector />);

expect(screen.getByText('JavaScript')).toBeInTheDocument();
expect(screen.getByText('icon-tech-javascript')).toBeInTheDocument();
// No dropdown chevron and no combobox trigger when there is nothing to choose between.
expect(screen.queryByText('icon-gui-chevron-down-solid')).not.toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});

it('navigates when a language option is selected', async () => {
render(<LanguageSelector />);
const trigger = screen.getByRole('combobox', { name: /select code language/i });
Expand Down
42 changes: 38 additions & 4 deletions src/components/Layout/LanguageSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,27 +64,43 @@ const SingleLanguageSelector = () => {
};

if (!selectedOption) {
return <Skeleton className="w-[180px] h-5 my-[9px]" />;
return <Skeleton className="w-[195px] h-5 my-[9px]" />;
}

const selectedLang = languageInfo[selectedOption.label];

// With only one language available there is nothing to choose between, so render a
// static, non-interactive element. It keeps the same bordered styling as the dropdown
// trigger but drops the chevron and any dropdown behaviour.
if (options.length <= 1) {
return (
<div className="flex items-center md:relative w-full">
<div className={cn(secondaryButtonClassName, 'gap-1.5')} aria-label="Code language">
<Icon size="20px" name={`icon-tech-${selectedLang?.alias ?? selectedOption.label}` as IconName} />
<span className="font-semibold">{selectedLang?.label}</span>
<Badge color="neutral" size="xs" className="my-px">
v{selectedOption.version}
</Badge>
</div>
</div>
);
}

return (
<div className="flex items-center md:relative w-full">
<Select.Root value={value} onValueChange={handleValueChange}>
<Select.Trigger asChild>
<button
className={cn(secondaryButtonClassName, 'gap-1.5', options.length > 1 ? 'cursor-pointer' : 'cursor-auto')}
className={cn(secondaryButtonClassName, 'gap-1.5 w-[195px] justify-start cursor-pointer')}
aria-label="Select code language"
disabled={options.length === 1}
>
<Icon size="20px" name={`icon-tech-${selectedLang?.alias ?? selectedOption.label}` as IconName} />
<span className="font-semibold">{selectedLang?.label}</span>
<Badge color="neutral" size="xs" className="my-px">
v{selectedOption.version}
</Badge>
{options.length > 1 && (
<Select.Icon className="flex items-center">
<Select.Icon className="flex items-center ml-auto">
<Icon name="icon-gui-chevron-down-solid" size="12px" />
</Select.Icon>
)}
Expand Down Expand Up @@ -212,6 +228,24 @@ const DualLanguageDropdown = ({ label, paramName, languages, selectedLanguage }:

const selectedLang = languageInfo[selectedOption.label];

// With only one language available there is nothing to choose between, so render a
// static, non-interactive element. It keeps the same bordered styling as the dropdown
// trigger but drops the chevron and any dropdown behaviour.
if (options.length <= 1) {
return (
<div className="flex items-center gap-2">
<span className="text-p4 font-semibold text-neutral-900 dark:text-neutral-400 whitespace-nowrap">{label}</span>
<div className={cn(secondaryButtonClassName, 'gap-1.5')} aria-label={`${label} language`}>
<Icon size="20px" name={`icon-tech-${selectedLang?.alias ?? selectedOption.label}` as IconName} />
<span className="font-semibold">{selectedLang?.label}</span>
<Badge color="neutral" size="xs" className="my-px">
v{selectedOption.version}
</Badge>
</div>
</div>
);
}

return (
<div className="flex items-center gap-2">
<span className="text-p4 font-semibold text-neutral-900 dark:text-neutral-400 whitespace-nowrap">{label}</span>
Expand Down
27 changes: 17 additions & 10 deletions src/components/Layout/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HEADER_HEIGHT } from '@ably/ui/core/utils/heights';
import Icon from '@ably/ui/core/Icon';

import { productData } from 'src/data';
import { ProductKey } from 'src/data/types';
import { NavProductContent, NavProductPage } from 'src/data/nav/types';
import Link from '../Link';
import { useLayoutContext } from 'src/contexts/layout-context';
Expand Down Expand Up @@ -38,10 +39,14 @@ const buildLinkWithParams = (targetLink: string, searchParams: URLSearchParams):
type LeftSidebarProps = {
className?: string;
inHeader?: boolean;
// Override which product's nav to render. Omit to follow the active page (default,
// desktop). Pass a key to force that product (e.g. the mobile Platform/Products tabs),
// or null to show the "select a product" placeholder.
product?: ProductKey | null;
};

const accordionContentClassName =
'overflow-hidden data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up p-1 -m-1';
'overflow-hidden data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up p-1 -m-0.5';

const accordionTriggerClassName = cn(
interactiveButtonClassName,
Expand Down Expand Up @@ -195,7 +200,7 @@ const SectionNav = ({ content, tree }: { content: (NavProductPage | NavProductCo

return (
<div key={page.name}>
<div className="ui-text-label2 font-bold text-neutral-1300 dark:text-neutral-000 pb-1.5 pt-5 pl-3 pr-2">
<div className="ui-text-label2 font-bold text-neutral-1300 dark:text-neutral-000 pb-2 pt-5 pl-3 pr-2">
{page.name}
</div>
{hasDeeperLayer && <ChildAccordion content={page.pages} tree={[...tree, index]} />}
Expand All @@ -206,7 +211,7 @@ const SectionNav = ({ content, tree }: { content: (NavProductPage | NavProductCo
);
};

const LeftSidebar = ({ className, inHeader = false }: LeftSidebarProps) => {
const LeftSidebar = ({ className, inHeader = false, product }: LeftSidebarProps) => {
const { activePage } = useLayoutContext();

const {
Expand All @@ -225,8 +230,9 @@ const LeftSidebar = ({ className, inHeader = false }: LeftSidebarProps) => {
}
`);

// Resolve the active product's nav content
const activeProductKey = activePage.product;
// Resolve which product's nav content to render. An explicit `product` prop (including
// null) overrides the active page; omitting it follows the active page as before.
const activeProductKey = product === undefined ? activePage.product : product;
const activeProduct = activeProductKey ? productData[activeProductKey] : null;
const content = useMemo(
() => (activeProduct ? [...activeProduct.nav.content, ...activeProduct.nav.api] : []),
Expand All @@ -238,11 +244,12 @@ const LeftSidebar = ({ className, inHeader = false }: LeftSidebarProps) => {

const stickyTopPx = HEADER_HEIGHT + (activePage.hasProductBar ? PRODUCT_BAR_HEIGHT : 0);
const stickyTopStyle = inHeader ? undefined : { top: `${stickyTopPx}px`, height: `calc(100dvh - ${stickyTopPx}px)` };
const stickyTopClass = inHeader ? 'top-16' : '';

return (
<div
className={cn('sticky', stickyTopClass, inHeader ? 'w-full' : 'w-[300px] hidden md:block', className)}
// In the mobile menu the sidebar flows at natural height inside the menu's single
// scroll area; on desktop it's a sticky, self-scrolling column.
className={cn(inHeader ? 'w-full' : 'sticky w-[300px] hidden md:block', className)}
style={stickyTopStyle}
>
{inHeader && (
Expand All @@ -256,10 +263,10 @@ const LeftSidebar = ({ className, inHeader = false }: LeftSidebarProps) => {
)}
<div
className={cn(
'bg-neutral-000 dark:bg-neutral-1300 overflow-x-hidden overflow-y-auto',
'bg-neutral-000 dark:bg-neutral-1300 overflow-x-hidden',
inHeader
? 'w-full h-[calc(100dvh-64px-128px)]'
: 'w-[300px] h-full border-r border-neutral-300 dark:border-neutral-1000 pt-2',
? 'w-full'
: 'w-[300px] h-full overflow-y-auto border-r border-neutral-300 dark:border-neutral-1000 pt-2',
)}
>
{content.length > 0 ? (
Expand Down
67 changes: 50 additions & 17 deletions src/components/Layout/ProductBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const buildNavBarItems = (): NavBarItem[] => {
// --- Styles ---

const tabBaseClassName = cn(
'flex items-center gap-1.5 px-3 py-2 whitespace-nowrap rounded-lg transition-colors',
'flex items-center gap-1.5 h-9 px-3 whitespace-nowrap rounded-lg transition-colors',
'ui-text-label3 font-semibold',
'focus-base',
);
Expand All @@ -63,20 +63,34 @@ const inactiveTabClassName = cn(

type ProductBarProps = {
className?: string;
// When provided, product items act as selectors (used by the mobile menu) instead of
// navigating: clicking calls onSelectProduct and the active highlight follows
// selectedProduct rather than the active page.
onSelectProduct?: (key: ProductKey) => void;
selectedProduct?: ProductKey | null;
};

const ProductBar = ({ className }: ProductBarProps) => {
const ProductBar = ({ className, onSelectProduct, selectedProduct }: ProductBarProps) => {
const { activePage } = useLayoutContext();
const items = useMemo(buildNavBarItems, []);
// Selection mode == the mobile menu: products wrap onto multiple rows (no horizontal
// scroll) and the bar pins to the top of the menu's single scroll area.
const selectionMode = !!onSelectProduct;

return (
<nav
className={cn(
'sticky top-16 z-30 bg-neutral-000 dark:bg-neutral-1300 border-b border-neutral-300 dark:border-neutral-1000',
'z-30 bg-neutral-000 dark:bg-neutral-1300 border-b border-neutral-300 dark:border-neutral-1000',
selectionMode ? 'sticky top-0' : 'sticky top-16',
className,
)}
>
<div className="flex items-center gap-0.5 px-5 py-2 overflow-x-auto scrollbar-none max-w-[1600px] mx-auto">
<div
className={cn(
'gap-0.5 pl-2 pr-5 py-2 max-w-[1600px] mx-auto',
selectionMode ? 'flex flex-wrap' : 'flex items-center overflow-x-auto scrollbar-none',
)}
>
{items.map((item, index) => {
if (item.type === 'divider') {
return (
Expand All @@ -88,7 +102,11 @@ const ProductBar = ({ className }: ProductBarProps) => {
}

const isProduct = item.type === 'product';
const isActive = isProduct ? activePage.product === item.key : activePage.page.link === item.link;
const isActive = isProduct
? selectionMode
? selectedProduct === item.key
: activePage.product === item.key
: activePage.page.link === item.link;

const iconName = isProduct
? isActive
Expand All @@ -97,18 +115,9 @@ const ProductBar = ({ className }: ProductBarProps) => {
: (item as CustomBarItem).icon;

const itemKey = isProduct ? item.key : item.link;

return (
<Link
key={itemKey}
to={item.link}
className={cn(tabBaseClassName, isActive ? activeTabClassName : inactiveTabClassName)}
{...(!isProduct &&
(item as CustomBarItem).external && {
target: '_blank',
rel: 'noopener noreferrer',
})}
>
const tabClassName = cn(tabBaseClassName, isActive ? activeTabClassName : inactiveTabClassName);
const tabContent = (
<>
{iconName && (
<Icon
name={iconName}
Expand All @@ -120,6 +129,30 @@ const ProductBar = ({ className }: ProductBarProps) => {
{!isProduct && (item as CustomBarItem).external && (
<Icon name="icon-gui-arrow-top-right-on-square-outline" size="12px" />
)}
</>
);

// Selection mode (mobile): pick a product to reveal its TOC instead of navigating.
if (selectionMode && isProduct) {
return (
<button key={itemKey} type="button" className={tabClassName} onClick={() => onSelectProduct(item.key)}>
{tabContent}
</button>
);
}

return (
<Link
key={itemKey}
to={item.link}
className={tabClassName}
{...(!isProduct &&
(item as CustomBarItem).external && {
target: '_blank',
rel: 'noopener noreferrer',
})}
>
{tabContent}
</Link>
);
})}
Expand Down
Loading