Refactor Header and StructurePreview components to utilize HeaderLayout for improved structure and consistency in header rendering

This commit is contained in:
Nicolas Dorseuil
2026-06-16 13:31:47 +02:00
parent 96602eb552
commit 27d9ad6fe4
3 changed files with 275 additions and 321 deletions
+72 -163
View File
@@ -1,12 +1,12 @@
import type { GitBookSiteContext } from '@/lib/context';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import type { SiteSpace } from '@gitbook/api';
import { SocialAccountButton } from '../Footer/SocialAccounts';
import { SearchContainer, getSearchBaseProps } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLayout } from './HeaderLayout';
import { HeaderLink } from './HeaderLink';
import { HeaderLinkMore } from './HeaderLinkMore';
import { HeaderLinks } from './HeaderLinks';
@@ -41,172 +41,81 @@ export async function Header(props: {
);
return (
<header
data-gb-site-header
className={tcls(
'flex',
'flex-col',
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky',
'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30',
'w-full',
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
withTopHeader ? null : 'mobile-only lg:hidden',
'text-sm',
'backdrop-blur-lg'
)}
>
<div
className={tcls(
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:shadow-[0px_1px_0px]',
'site-header:theme-bold:shadow-tint-12/2'
)}
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
data-gb-header-content
<HeaderLayout
withTopHeader={withTopHeader}
searchStyle={customization.styling.search}
leading={
<>
<HeaderMobileMenu
className={tcls(
'gap-4',
'lg:gap-6',
'flex',
'items-center',
'justify-between',
'w-full',
'py-3',
'min-h-16',
'sm:h-16',
CONTAINER_STYLE,
'transition-[max-width] duration-300 motion-reduce:transition-none',
'@container/header'
'-ml-2',
'text-tint-strong',
'site-header:theme-bold:text-header-link',
'hover:bg-tint-hover',
'hover:site-header:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'no-sidebar:hidden lg:hidden'
)}
>
<div
className={tcls(
'flex max-w-full',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? 'lg:@2xl:basis-72'
: null
)}
>
<HeaderMobileMenu
className={tcls(
'-ml-2',
'text-tint-strong',
'site-header:theme-bold:text-header-link',
'hover:bg-tint-hover',
'hover:site-header:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'no-sidebar:hidden lg:hidden'
)}
/>
<HeaderLogo context={context} />
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'transition-[margin] duration-300 motion-reduce:transition-none',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'md:@2xl:mr-auto',
'order-last',
'md:@2xl:order-[unset]',
]
: ['order-last']
)}
>
<SearchContainer
{...searchProps}
style={customization.styling.search}
viewport={!withTopHeader ? 'mobile' : undefined}
/>
</div>
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.map((link) => {
/>
<HeaderLogo context={context} />
</>
}
search={
<SearchContainer
{...searchProps}
style={customization.styling.search}
viewport={!withTopHeader ? 'mobile' : undefined}
/>
}
links={
customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.map((link) => {
return <HeaderLink key={link.title} link={link} context={context} />;
})}
{headerSocialAccounts.length > 0 ? (
<div className="flex items-center gap-1">
{headerSocialAccounts.map((account) => {
return (
<HeaderLink
key={link.title}
link={link}
context={context}
<SocialAccountButton
key={`${account.platform}-${account.handle}`}
account={account}
target={customization.externalLinks.target}
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
/>
);
})}
{headerSocialAccounts.length > 0 ? (
<div className="flex items-center gap-1">
{headerSocialAccounts.map((account) => {
return (
<SocialAccountButton
key={`${account.platform}-${account.handle}`}
account={account}
target={customization.externalLinks.target}
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
/>
);
})}
</div>
) : null}
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<HeaderLinkMore
label={t(language, 'more')}
links={customization.header.links}
socialAccounts={headerSocialAccounts}
context={context}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={
variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
/>
) : null}
</HeaderLinks>
</div>
) : null}
</div>
</div>
</div>
{visibleSections && withSections ? (
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<HeaderLinkMore
label={t(language, 'more')}
links={customization.header.links}
socialAccounts={headerSocialAccounts}
context={context}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={
variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
/>
) : null}
</HeaderLinks>
) : null
}
sections={
visibleSections && withSections ? (
<SiteSectionTabs sections={encodeClientSiteSections(context, visibleSections)}>
{variants.translations.length > 1 ? (
<TranslationsDropdown
@@ -221,8 +130,8 @@ export async function Header(props: {
/>
) : null}
</SiteSectionTabs>
</div>
) : null}
</header>
) : null
}
/>
);
}
@@ -0,0 +1,127 @@
import type { CustomizationSearchStyle } from '@gitbook/api';
import type React from 'react';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { tcls } from '@/lib/tailwind';
const PROMINENT_SEARCH_STYLE: CustomizationSearchStyle = 'prominent' as CustomizationSearchStyle;
/**
* Shared visual layout for the site header.
*
* The live site and structure preview provide different interactive pieces, but the shell,
* spacing, responsive behavior, and theme classes should stay identical.
*/
export function HeaderLayout(props: {
leading: React.ReactNode;
search: React.ReactNode;
searchStyle: CustomizationSearchStyle;
withTopHeader?: boolean;
links?: React.ReactNode;
sections?: React.ReactNode;
}) {
const { leading, search, searchStyle, withTopHeader, links, sections } = props;
const hasProminentSearch = searchStyle === PROMINENT_SEARCH_STYLE;
return (
<header
data-gb-site-header
className={tcls(
'flex',
'flex-col',
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky',
'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30',
'w-full',
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
withTopHeader ? null : 'mobile-only lg:hidden',
'text-sm',
'backdrop-blur-lg'
)}
>
<div
className={tcls(
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:shadow-[0px_1px_0px]',
'site-header:theme-bold:shadow-tint-12/2'
)}
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
data-gb-header-content
className={tcls(
'gap-4',
'lg:gap-6',
'flex',
'items-center',
'justify-between',
'w-full',
'py-3',
'min-h-16',
'sm:h-16',
CONTAINER_STYLE,
'transition-[max-width] duration-300 motion-reduce:transition-none',
'@container/header'
)}
>
<div
className={tcls(
'flex max-w-full',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
hasProminentSearch ? 'lg:@2xl:basis-72' : null
)}
>
{leading}
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'transition-[margin] duration-300 motion-reduce:transition-none',
hasProminentSearch
? [
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]',
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]',
'md:@2xl:mr-auto',
'order-last',
'md:@2xl:order-[unset]',
]
: ['order-last']
)}
>
{search}
</div>
{links}
</div>
</div>
</div>
{sections ? (
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
{sections}
</div>
) : null}
</header>
);
}
@@ -13,6 +13,7 @@ import { tcls } from '@/lib/tailwind';
import { tString, useLanguage } from '@/intl/client';
import { AIChatButtonView, AIChatIcon, getAIChatName } from '../AIChat';
import { HeaderLayout } from '../Header/HeaderLayout';
import {
HeaderLinkItem,
HeaderLinkMenuItem,
@@ -39,7 +40,6 @@ import {
} from '../Header/SpacesDropdownData';
import headerLinksStyles from '../Header/headerLinks.module.css';
import { SearchHeaderInput } from '../Search';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '../layout';
import { Button } from '../primitives';
import {
SOCIAL_PLATFORM_ICONS,
@@ -112,161 +112,79 @@ function StructurePreviewHeader(props: { snapshot: StructurePreviewSnapshot }) {
);
return (
<header
data-gb-site-header
className={tcls(
'flex',
'flex-col',
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky',
'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30',
'w-full',
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
withTopHeader ? null : 'mobile-only lg:hidden',
'text-sm',
'backdrop-blur-lg'
)}
>
<div
className={tcls(
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:shadow-[0px_1px_0px]',
'site-header:theme-bold:shadow-tint-12/2'
)}
>
<div>
<div
data-gb-header-content
className={tcls(
'gap-4',
'lg:gap-6',
'flex',
'items-center',
'justify-between',
'w-full',
'py-3',
'min-h-16',
'sm:h-16',
CONTAINER_STYLE,
'@container/header'
)}
>
<div
className={tcls(
'flex max-w-full',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? 'lg:@2xl:basis-72'
: null
)}
>
<StructurePreviewLogo snapshot={snapshot} />
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'md:@2xl:mr-auto',
'order-last',
'md:@2xl:order-[unset]',
]
: ['order-last']
)}
>
<StructurePreviewSearch />
{previewAssistants.map((assistant, index) => (
<AIChatButtonView
key={assistant.id}
icon={assistant.icon}
label={assistant.label}
withShortcut={index === 0}
showLabel={
previewAssistants.length === 1 &&
customization.styling.search ===
CustomizationSearchStyle.Prominent
}
inert
/>
))}
</div>
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.map((link, index) => (
<StructurePreviewHeaderLink
key={`${getContentRefKey(link.to)}-${index}`}
link={link}
snapshot={snapshot}
/>
))}
{headerSocialAccounts.length > 0 ? (
<div className="flex items-center gap-1">
{headerSocialAccounts.map((account) => {
const icon = SOCIAL_PLATFORM_ICONS[account.platform];
return icon ? (
<Button
key={`${account.platform}-${account.handle}`}
iconOnly
label={account.platform}
icon={icon}
variant="blank"
size="large"
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
/>
) : null;
})}
</div>
) : null}
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<StructurePreviewMoreMenu
label={tString(language, 'more')}
links={customization.header.links}
snapshot={snapshot}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
snapshot={snapshot}
siteSpace={snapshot.siteSpace}
siteSpaces={variants.translations}
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
/>
) : null}
</HeaderLinks>
<HeaderLayout
withTopHeader={withTopHeader}
searchStyle={customization.styling.search}
leading={<StructurePreviewLogo snapshot={snapshot} />}
search={
<>
<StructurePreviewSearch />
{previewAssistants.map((assistant, index) => (
<AIChatButtonView
key={assistant.id}
icon={assistant.icon}
label={assistant.label}
withShortcut={index === 0}
showLabel={
previewAssistants.length === 1 &&
customization.styling.search === CustomizationSearchStyle.Prominent
}
inert
/>
))}
</>
}
links={
customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.map((link, index) => (
<StructurePreviewHeaderLink
key={`${getContentRefKey(link.to)}-${index}`}
link={link}
snapshot={snapshot}
/>
))}
{headerSocialAccounts.length > 0 ? (
<div className="flex items-center gap-1">
{headerSocialAccounts.map((account) => {
const icon = SOCIAL_PLATFORM_ICONS[account.platform];
return icon ? (
<Button
key={`${account.platform}-${account.handle}`}
iconOnly
label={account.platform}
icon={icon}
variant="blank"
size="large"
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
/>
) : null;
})}
</div>
) : null}
</div>
</div>
</div>
{sections && withSections ? (
<div>
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<StructurePreviewMoreMenu
label={tString(language, 'more')}
links={customization.header.links}
snapshot={snapshot}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
snapshot={snapshot}
siteSpace={snapshot.siteSpace}
siteSpaces={variants.translations}
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
/>
) : null}
</HeaderLinks>
) : null
}
sections={
sections && withSections ? (
<SiteSectionTabs sections={sections}>
{variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
@@ -277,9 +195,9 @@ function StructurePreviewHeader(props: { snapshot: StructurePreviewSnapshot }) {
/>
) : null}
</SiteSectionTabs>
</div>
) : null}
</header>
) : null
}
/>
);
}