Skip to content

Commit 7911350

Browse files
authored
Add language selector to site header (#3622)
1 parent 12c9d76 commit 7911350

File tree

12 files changed

+131
-100
lines changed

12 files changed

+131
-100
lines changed

.changeset/four-roses-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": minor
3+
---
4+
5+
Add language selector to site header

packages/gitbook/src/components/Header/Header.tsx

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ import { HeaderLinkMore } from './HeaderLinkMore';
1010
import { HeaderLinks } from './HeaderLinks';
1111
import { HeaderLogo } from './HeaderLogo';
1212
import { HeaderMobileMenu } from './HeaderMobileMenu';
13-
import { SpacesDropdown } from './SpacesDropdown';
13+
import { TranslationsDropdown } from './SpacesDropdown';
1414

1515
/**
1616
* Render the header for the space.
1717
*/
1818
export function Header(props: {
1919
context: GitBookSiteContext;
2020
withTopHeader?: boolean;
21+
withVariants?: 'generic' | 'translations';
2122
}) {
22-
const { context, withTopHeader } = props;
23+
const { context, withTopHeader, withVariants } = props;
2324
const { siteSpace, siteSpaces, sections, customization } = context;
2425

2526
return (
@@ -85,7 +86,7 @@ export function Header(props: {
8586
'theme-bold:text-header-link',
8687
'hover:bg-tint-hover',
8788
'hover:theme-bold:bg-header-link/3',
88-
'page-no-toc:hidden'
89+
withVariants === 'generic' ? '' : 'page-no-toc:hidden'
8990
)}
9091
/>
9192
<HeaderLogo context={context} />
@@ -125,29 +126,42 @@ export function Header(props: {
125126
/>
126127
</div>
127128

128-
{customization.header.links.length > 0 && (
129+
{customization.header.links.length > 0 ||
130+
(!sections && withVariants === 'translations') ? (
129131
<HeaderLinks>
130-
{customization.header.links.map((link) => {
131-
return (
132-
<HeaderLink
133-
key={link.title}
134-
link={link}
132+
{customization.header.links.length > 0 ? (
133+
<>
134+
{customization.header.links.map((link) => {
135+
return (
136+
<HeaderLink
137+
key={link.title}
138+
link={link}
139+
context={context}
140+
/>
141+
);
142+
})}
143+
<HeaderLinkMore
144+
label={t(getSpaceLanguage(context), 'more')}
145+
links={customization.header.links}
135146
context={context}
136147
/>
137-
);
138-
})}
139-
<HeaderLinkMore
140-
label={t(getSpaceLanguage(context), 'more')}
141-
links={customization.header.links}
142-
context={context}
143-
/>
148+
</>
149+
) : null}
150+
{!sections && withVariants === 'translations' ? (
151+
<TranslationsDropdown
152+
context={context}
153+
siteSpace={siteSpace}
154+
siteSpaces={siteSpaces}
155+
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
156+
/>
157+
) : null}
144158
</HeaderLinks>
145-
)}
159+
) : null}
146160
</div>
147161
</div>
148162
</div>
149163

150-
{sections || siteSpaces.length > 1 ? (
164+
{sections ? (
151165
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
152166
<div
153167
className={tcls(
@@ -167,26 +181,21 @@ export function Header(props: {
167181
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
168182
)}
169183
>
170-
{siteSpaces.length > 1 && (
171-
<div
172-
id="variants"
173-
className="my-2 mr-5 grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
174-
>
175-
<SpacesDropdown
184+
{sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
185+
sections.list.length > 1 ? ( // Otherwise, show the tabs if there's more than one section
186+
<SiteSectionTabs
187+
sections={encodeClientSiteSections(context, sections)}
188+
/>
189+
) : null}
190+
{withVariants === 'translations' ? (
191+
<div className="site-background before:contents[] -mr-4 sm:-mr-6 md:-mr-8 sticky inset-y-0 right-0 z-10 ml-6 flex h-full items-center py-2 pr-4 before:mr-4 before:h-full before:border-tint before:border-l sm:pr-6 md:pr-8">
192+
<TranslationsDropdown
176193
context={context}
177194
siteSpace={siteSpace}
178195
siteSpaces={siteSpaces}
179-
className="w-full grow py-1"
180196
/>
181197
</div>
182-
)}
183-
{sections &&
184-
(sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
185-
sections.list.length > 1) && ( // Otherwise, show the tabs if there's more than one section
186-
<SiteSectionTabs
187-
sections={encodeClientSiteSections(context, sections)}
188-
/>
189-
)}
198+
) : null}
190199
</div>
191200
</div>
192201
</div>

packages/gitbook/src/components/Header/HeaderLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function HeaderItemButton(
138138

139139
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
140140
return tcls(
141-
'flex items-center',
141+
'flex items-center gap-1',
142142
'shrink',
143143
'contrast-more:underline',
144144
'truncate',
Lines changed: 49 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import type { SiteSpace } from '@gitbook/api';
2+
import { useMemo } from 'react';
23

34
import type { GitBookSiteContext } from '@/lib/context';
45
import { getSiteSpaceURL } from '@/lib/sites';
56
import { tcls } from '@/lib/tailwind';
7+
import { Button, type ButtonProps } from '../primitives';
68
import { DropdownChevron, DropdownMenu } from '../primitives/DropdownMenu';
79
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
810

11+
// Memoized regex for checking if a string starts with an emoji
12+
const EMOJI_REGEX = /^\p{Emoji}/u;
13+
14+
function startsWithEmoji(text: string): boolean {
15+
return EMOJI_REGEX.test(text);
16+
}
17+
918
export function SpacesDropdown(props: {
1019
context: GitBookSiteContext;
1120
siteSpace: SiteSpace;
1221
siteSpaces: SiteSpace[];
1322
className?: string;
23+
variant?: ButtonProps['variant'];
24+
icon?: ButtonProps['icon'];
1425
}) {
15-
const { context, siteSpace, siteSpaces, className } = props;
26+
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
1627

1728
return (
1829
<DropdownMenu
@@ -21,48 +32,16 @@ export function SpacesDropdown(props: {
2132
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
2233
)}
2334
button={
24-
<div
35+
<Button
36+
icon={icon}
2537
data-testid="space-dropdown-button"
26-
className={tcls(
27-
'flex',
28-
'flex-row',
29-
'items-center',
30-
'transition-all',
31-
'hover:cursor-pointer',
32-
33-
'px-3',
34-
'py-2',
35-
'gap-2',
36-
37-
'rounded-md',
38-
'straight-corners:rounded-none',
39-
40-
'bg-tint-base',
41-
42-
'text-sm',
43-
'text-tint',
44-
'hover:text-tint-strong',
45-
'data-[state=open]:text-tint-strong',
46-
47-
'ring-1',
48-
'ring-tint-subtle',
49-
'hover:ring-tint-hover',
50-
'data-[state=open]:ring-tint-hover',
51-
52-
'contrast-more:bg-tint-base',
53-
'contrast-more:ring-1',
54-
'contrast-more:hover:ring-2',
55-
'contrast-more:data-[state=open]:ring-2',
56-
'contrast-more:ring-tint',
57-
'contrast-more:hover:ring-tint-hover',
58-
'contrast-more:data-[state=open]:ring-tint-hover',
59-
60-
className
61-
)}
38+
size="medium"
39+
variant={variant}
40+
trailing={<DropdownChevron />}
41+
className={tcls('bg-tint-base', className)}
6242
>
63-
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
64-
<DropdownChevron />
65-
</div>
43+
<span className="button-content">{siteSpace.title}</span>
44+
</Button>
6645
}
6746
>
6847
<SpacesDropdownMenuItems
@@ -77,3 +56,32 @@ export function SpacesDropdown(props: {
7756
</DropdownMenu>
7857
);
7958
}
59+
60+
export function TranslationsDropdown(props: {
61+
context: GitBookSiteContext;
62+
siteSpace: SiteSpace;
63+
siteSpaces: SiteSpace[];
64+
className?: string;
65+
}) {
66+
const { context, siteSpace, siteSpaces, className } = props;
67+
68+
// Memoize the emoji check to avoid repeated regex execution
69+
const hasEmojiPrefix = useMemo(() => startsWithEmoji(siteSpace.title), [siteSpace.title]);
70+
71+
return (
72+
<SpacesDropdown
73+
icon="globe"
74+
context={context}
75+
siteSpace={siteSpace}
76+
siteSpaces={siteSpaces}
77+
variant="blank"
78+
className={tcls(
79+
'-mx-2 bg-transparent px-2 py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
80+
hasEmojiPrefix
81+
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
82+
: '',
83+
className
84+
)}
85+
/>
86+
);
87+
}

packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -177,16 +177,7 @@ export async function CustomizationRootLayout(props: {
177177
}
178178
`}</style>
179179
</head>
180-
<body
181-
className={tcls(
182-
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
183-
'bg-tint-base',
184-
'theme-muted:bg-tint-subtle',
185-
186-
'theme-gradient:bg-gradient-primary',
187-
'theme-gradient-tint:bg-gradient-tint'
188-
)}
189-
>
180+
<body className="site-background">
190181
<IconsProvider
191182
assetsURL={GITBOOK_ICONS_URL}
192183
assetsURLToken={GITBOOK_ICONS_TOKEN}

packages/gitbook/src/components/RootLayout/globals.css

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161

6262
@utility gutter-stable {
6363
scrollbar-gutter: stable;
64-
scrollbar-gutter: stable;
6564
}
6665

6766
@utility triangle {
@@ -158,6 +157,15 @@
158157
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
159158
}
160159

160+
@utility site-background {
161+
@apply [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle;
162+
@apply bg-tint-base;
163+
@apply theme-muted:bg-tint-subtle;
164+
165+
@apply theme-gradient:bg-gradient-primary;
166+
@apply theme-gradient-tint:bg-gradient-tint;
167+
}
168+
161169
@utility elevate-link {
162170
& a[href]:not(.link-overlay) {
163171
position: relative;

packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TableOfContents } from '@/components/TableOfContents';
1212
import { CONTAINER_STYLE } from '@/components/layout';
1313
import { tcls } from '@/lib/tailwind';
1414

15+
import { getSpaceLanguage } from '@/intl/server';
1516
import type { VisitorAuthClaims } from '@/lib/adaptive';
1617
import { GITBOOK_APP_URL } from '@/lib/env';
1718
import { AIChatProvider } from '../AI';
@@ -103,7 +104,16 @@ export function SpaceLayout(props: SpaceLayoutProps) {
103104
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
104105

105106
const withSections = Boolean(sections && sections.list.length > 1);
106-
const isMultiVariants = Boolean(siteSpaces.length > 1);
107+
108+
const currentLanguage = getSpaceLanguage(context);
109+
const withVariants: 'generic' | 'translations' | undefined =
110+
siteSpaces.length > 1
111+
? siteSpaces.some(
112+
(space) => space.space.language && space.space.language !== currentLanguage.locale
113+
)
114+
? 'translations'
115+
: 'generic'
116+
: undefined;
107117

108118
const withFooter =
109119
customization.themes.toggeable ||
@@ -114,7 +124,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
114124
return (
115125
<SpaceLayoutServerContext {...props}>
116126
<Announcement context={context} />
117-
<Header withTopHeader={withTopHeader} context={context} />
127+
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
118128
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
119129
<AIChat trademark={customization.trademark.enabled} />
120130
) : null}
@@ -176,16 +186,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
176186
sections={encodeClientSiteSections(context, sections)}
177187
/>
178188
)}
179-
{isMultiVariants && !sections && (
189+
{withVariants === 'generic' && (
180190
<SpacesDropdown
181191
context={context}
182192
siteSpace={siteSpace}
183193
siteSpaces={siteSpaces}
184-
className={tcls(
185-
'w-full',
186-
'page-no-toc:hidden',
187-
'page-no-toc:site-header-none:flex'
188-
)}
194+
className="w-full px-3 py-2"
189195
/>
190196
)}
191197
</>

packages/gitbook/src/components/TableOfContents/TableOfContents.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
33
import type React from 'react';
44

55
import { tcls } from '@/lib/tailwind';
6-
76
import { PagesList } from './PagesList';
87
import { TOCScrollContainer } from './TOCScroller';
98
import { TableOfContentsScript } from './TableOfContentsScript';
@@ -57,6 +56,8 @@ export async function TableOfContents(props: {
5756
// Client-side dynamic positioning (CSS vars applied by script)
5857
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
5958
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
59+
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
60+
'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!',
6061

6162
'pt-6',
6263
'pb-4',

0 commit comments

Comments
 (0)