Compare commits

...

27 Commits

Author SHA1 Message Date
Zeno Kapitein 365f60d2ca First try 2025-12-17 15:19:07 +01:00
Zeno Kapitein c716df39ba Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-16 19:49:33 +01:00
Zeno Kapitein 8ee84f130f Update SpaceLayout.tsx 2025-12-12 11:53:46 +01:00
Zeno Kapitein aa86711d5a More spacing 2025-12-11 19:33:38 +01:00
Zeno Kapitein 5347917a47 Spacing 2025-12-11 19:16:24 +01:00
Zeno Kapitein 43e8deca0f Add margin to first document item 2025-12-11 17:56:49 +01:00
Zeno Kapitein 4cdab37eac Rework table of contents to accommodate faded edges 2025-12-11 17:27:49 +01:00
Zeno Kapitein 8a887fd890 Fix tests 2025-12-11 14:39:00 +01:00
Zeno Kapitein 2f6ea5b1f0 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-11 14:28:55 +01:00
Zeno Kapitein dcd03554bd Update TOC to use normal scrollcontainer 2025-12-11 14:28:26 +01:00
Zeno Kapitein 9281b5e3b7 Update SideSheet.tsx 2025-12-11 14:26:46 +01:00
Zeno Kapitein ec0b6075b0 Update AnnouncementBanner.tsx 2025-12-11 10:24:15 +01:00
Zeno Kapitein e372dae552 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-11 10:11:28 +01:00
Zeno Kapitein e329b41764 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-05 14:13:46 +01:00
Zeno Kapitein c8956720d0 Update Header.tsx 2025-12-04 18:32:37 +01:00
Zeno Kapitein 843635225d Make sidesheet work well in docs embed 2025-12-04 18:17:31 +01:00
Zeno Kapitein f78e9ca34e Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-04 17:36:31 +01:00
Zeno Kapitein 72a06718e2 Layout 2025-12-02 19:22:21 +01:00
Zeno Kapitein 0a7979ede2 More layout fixes 2025-12-02 17:49:51 +01:00
Zeno Kapitein fb49a27cc1 Layout tweaks 2025-12-02 16:04:39 +01:00
Zeno Kapitein 555a1da3ab Changeset 2025-12-02 14:10:13 +01:00
Zeno Kapitein b9f9623970 Fix scroll gutter 2025-12-02 14:08:23 +01:00
Zeno Kapitein a7b80c3c4f Small fixes 2025-12-02 13:54:32 +01:00
Zeno Kapitein e05f262f46 Get ready for supporting side sections list
(not enabled for now)
2025-12-02 13:50:38 +01:00
Zeno Kapitein f53e0427e4 Naming and docs 2025-12-02 12:52:02 +01:00
Zeno Kapitein 0d44191666 Fix trademark & other visual bugs 2025-12-02 11:40:28 +01:00
Zeno Kapitein 9e669f162a Add SideSheet component, refactor TOC and AIChat to use it
Also fixes RND-7803
2025-12-02 10:09:24 +01:00
29 changed files with 562 additions and 449 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
@@ -23,7 +23,8 @@ export default async function SiteDynamicLayout({
return ( return (
<CustomizationRootLayout <CustomizationRootLayout
className="site-background" htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
context={context} context={context}
> >
@@ -19,7 +19,11 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents(); const withTracking = shouldTrackEvents();
return ( return (
<CustomizationRootLayout className="site-background" context={context}> <CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
context={context}
>
<SiteLayout <SiteLayout
context={context} context={context}
withTracking={withTracking} withTracking={withTracking}
@@ -27,6 +27,7 @@ import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks'; import { useNow } from '../hooks';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer'; import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControlButton } from './AIChatControlButton'; import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon'; import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput'; import { AIChatInput } from './AIChatInput';
@@ -69,16 +70,23 @@ export function AIChat() {
}, [chat.opened, trackEvent]); }, [chat.opened, trackEvent]);
return ( return (
<div <SideSheet
side="right"
open={chat.opened}
onOpenChange={(open) => {
if (open) {
chatController.open();
} else {
chatController.close();
}
}}
data-testid="ai-chat" data-testid="ai-chat"
withScrim={true}
className={tcls( className={tcls(
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96', 'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
)} )}
> >
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96"> <EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain> <EmbeddableFrameMain>
<EmbeddableFrameHeader> <EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} /> <AIChatDynamicIcon trademark={config.trademark} />
@@ -109,7 +117,7 @@ export function AIChat() {
</EmbeddableFrameBody> </EmbeddableFrameBody>
</EmbeddableFrameMain> </EmbeddableFrameMain>
</EmbeddableFrame> </EmbeddableFrame>
</div> </SideSheet>
); );
} }
@@ -220,8 +228,8 @@ export function AIChatBody(props: {
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]" className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4" contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical" orientation="vertical"
fadeEdges={['leading']} trailing={{ fade: false, button: true }}
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`} active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
> >
{isEmpty ? ( {isEmpty ? (
<div className="flex grow flex-col"> <div className="flex grow flex-col">
@@ -35,66 +35,68 @@ export function AnnouncementBanner(props: {
data-nosnippet="" data-nosnippet=""
> >
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}> <div className={tcls(CONTAINER_STYLE)}>
<Tag <div className="relative">
href={contentRef?.href ?? ''} <Tag
className={tcls( href={contentRef?.href ?? ''}
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors', className={tcls(
style.container, 'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
closeable && 'pr-12', style.container,
hasLink && style.hover closeable && 'pr-12',
)} hasLink && style.hover
insights={ )}
announcement.link insights={
? { announcement.link
type: 'link_click', ? {
link: { type: 'link_click',
target: announcement.link.to, link: {
position: SiteInsightsLinkPosition.Announcement, target: announcement.link.to,
}, position: SiteInsightsLinkPosition.Announcement,
} },
: undefined }
} : undefined
> }
<Icon >
icon={style.icon as IconName} <Icon
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`} icon={style.icon as IconName}
/> className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
<div> />
{announcement.message} <div>
{hasLink ? ( {announcement.message}
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}> {hasLink ? (
{contentRef?.icon ? ( <div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
<span className="mr-1 ml-2 *:inline"> {contentRef?.icon ? (
{contentRef?.icon} <span className="mr-1 ml-2 *:inline">
</span> {contentRef?.icon}
) : null} </span>
{announcement.link?.title && ( ) : null}
<span className="mr-1">{announcement.link?.title}</span> {announcement.link?.title && (
)} <span className="mr-1">{announcement.link?.title}</span>
<Icon )}
icon={ <Icon
announcement.link?.to.kind === 'url' icon={
? 'arrow-up-right' announcement.link?.to.kind === 'url'
: 'chevron-right' ? 'arrow-up-right'
} : 'chevron-right'
className={tcls('mb-0.5 inline size-3')} }
/> className={tcls('mb-0.5 inline size-3')}
</div> />
) : null} </div>
</div> ) : null}
</Tag> </div>
{closeable ? ( </Tag>
<Button {closeable ? (
iconOnly <Button
icon="close" iconOnly
label={tString(language, 'close')} icon="close"
variant="blank" label={tString(language, 'close')}
size="default" variant="blank"
onClick={dismissAnnouncement} size="default"
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`} onClick={dismissAnnouncement}
/> className={`absolute top-0 right-0 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 ${style.close}`}
) : null} />
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
aria-describedby={describedById} aria-describedby={describedById}
className={tcls( className={tcls(
'fixed', 'fixed',
'z-10', 'z-50',
'bg-tint-base', 'bg-tint-base',
'rounded-sm', 'rounded-sm',
'straight-corners:rounded-none', 'straight-corners:rounded-none',
@@ -52,9 +52,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'depth-flat:shadow-none', 'depth-flat:shadow-none',
'p-4', 'p-4',
'pr-8', 'pr-8',
'bottom-4', 'bottom-[max(env(safe-area-inset-bottom),1rem)]',
'right-4', 'right-[max(env(safe-area-inset-right),1rem)]',
'left-16', 'left-[max(env(safe-area-inset-left),4rem)]',
'max-w-md', 'max-w-md',
'text-balance', 'text-balance',
'sm:left-auto', 'sm:left-auto',
@@ -79,9 +79,9 @@ export async function EmbeddableDocsPage(
orientation="vertical" orientation="vertical"
className="not-hydrated:animate-blur-in-slow" className="not-hydrated:animate-blur-in-slow"
contentClassName="p-4" contentClassName="p-4"
fadeEdges={context.sections ? [] : ['leading']} leading={{ fade: !context.sections, button: true }}
> >
<TableOfContents className="pt-0" context={context} /> <TableOfContents context={context} />
<PageBody <PageBody
context={context} context={context}
page={page} page={page}
@@ -42,6 +42,7 @@ export function Header(props: {
`h-[${HEADER_HEIGHT_DESKTOP}px]`, `h-[${HEADER_HEIGHT_DESKTOP}px]`,
'sticky', 'sticky',
'top-0', 'top-0',
'pt-[env(safe-area-inset-top)]',
'z-30', 'z-30',
'w-full', 'w-full',
'flex-none', 'flex-none',
@@ -1,16 +1,13 @@
'use client'; 'use client';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react'; import { useEffect } from 'react';
import { tString, useLanguage } from '@/intl/client'; import { tString, useLanguage } from '@/intl/client';
import { useScrollListener } from '../hooks/useScrollListener';
import { Button, type ButtonProps } from '../primitives'; import { Button, type ButtonProps } from '../primitives';
const globalClassName = 'navigation-open'; const globalClassName = 'navigation-open';
const SCROLL_DISTANCE = 320;
/** /**
* Button to show/hide the table of content on mobile. * Button to show/hide the table of content on mobile.
*/ */
@@ -18,25 +15,6 @@ export function HeaderMobileMenu(props: ButtonProps) {
const language = useLanguage(); const language = useLanguage();
const pathname = usePathname(); const pathname = usePathname();
const hasScrollRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const toggleNavigation = () => {
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
document.body.classList.remove(globalClassName);
setIsOpen(false);
} else {
document.body.classList.add(globalClassName);
window.scrollTo(0, 0);
setIsOpen(true);
}
};
const windowRef = useRef(typeof window === 'undefined' ? null : window);
useScrollListener(() => {
hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE;
}, windowRef);
// Close the navigation when navigating to a page // Close the navigation when navigating to a page
useEffect(() => { useEffect(() => {
@@ -50,8 +28,10 @@ export function HeaderMobileMenu(props: ButtonProps) {
variant="blank" variant="blank"
size="default" size="default"
label={tString(language, 'table_of_contents_button_label')} label={tString(language, 'table_of_contents_button_label')}
onClick={toggleNavigation} onClick={() => {
active={isOpen} document.body.classList.toggle(globalClassName);
}}
// Since the button is hidden behind the TOC after toggling, we don't need to keep track of its active state.
{...props} {...props}
/> />
); );
@@ -43,6 +43,11 @@ export function PageAside(props: {
'opacity-0', 'opacity-0',
'xl:flex', 'xl:flex',
'xl:page-no-toc:fixed',
'xl:page-no-toc:mr-[max(0px,calc((100vw-96rem)/2-20rem))]',
'xl:page-no-toc:right-8',
'xl:page-no-toc:bottom-0',
'z-20',
'overflow-hidden', 'overflow-hidden',
@@ -102,7 +107,7 @@ export function PageAside(props: {
className={tcls( className={tcls(
'flex flex-col', 'flex flex-col',
'min-w-56 shrink-0', 'min-w-56 shrink-0',
'overflow-hidden', 'page-has-toc:overflow-hidden',
'w-full', 'w-full',
'xl:max-2xl:rounded-corners:page-api-block:rounded-md', 'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
'xl:max-2xl:circular-corners:page-api-block:rounded-xl', 'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
@@ -208,7 +213,7 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
<div <div
className={tcls( className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col', 'sticky bottom-0 z-10 mt-auto flex flex-col',
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', // 'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2', 'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'pt-4' 'pt-4'
)} )}
@@ -74,7 +74,7 @@ export function PageBody(props: {
// Allow words to break if they are too long. // Allow words to break if they are too long.
'break-anywhere', 'break-anywhere',
'@container', '@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default', pageWidthWide ? 'page-width-wide max-w-[92rem]' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default', siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents && hasVisibleTOCItems page.layout.tableOfContents && hasVisibleTOCItems
? 'page-has-toc' ? 'page-has-toc'
@@ -57,13 +57,15 @@ function preloadFont(fontData: FontData) {
* It takes care of setting the theme and the language. * It takes care of setting the theme and the language.
*/ */
export async function CustomizationRootLayout(props: { export async function CustomizationRootLayout(props: {
/** The class name to apply to the html element. */
htmlClassName?: string;
/** The class name to apply to the body element. */ /** The class name to apply to the body element. */
className?: string; bodyClassName?: string;
forcedTheme?: CustomizationThemeMode | null; forcedTheme?: CustomizationThemeMode | null;
context: GitBookAnyContext; context: GitBookAnyContext;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { className, context, forcedTheme, children } = props; const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
const customization = const customization =
'customization' in context ? context.customization : defaultCustomization(); 'customization' in context ? context.customization : defaultCustomization();
@@ -107,7 +109,8 @@ export async function CustomizationRootLayout(props: {
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled // Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark (forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
? 'dark' ? 'dark'
: '' : '',
htmlClassName
)} )}
> >
<head> <head>
@@ -179,7 +182,7 @@ export async function CustomizationRootLayout(props: {
} }
`}</style> `}</style>
</head> </head>
<body className={className}> <body className={tcls(bodyClassName, 'sheet-open:overflow-hidden')}>
<IconsProvider <IconsProvider
assetsURL={GITBOOK_ICONS_URL} assetsURL={GITBOOK_ICONS_URL}
assetsURLToken={GITBOOK_ICONS_TOKEN} assetsURLToken={GITBOOK_ICONS_TOKEN}
@@ -101,6 +101,7 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
viewportFit: 'cover',
}; };
} }
@@ -40,7 +40,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
orientation="vertical" orientation="vertical"
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }} style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="pb-4" className="pb-4"
active={currentSection.id} active={`#${currentSection.id}`}
> >
<div className="flex w-full flex-col px-2"> <div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => { {sectionsAndGroups.map((item) => {
@@ -76,8 +76,12 @@ export function SiteSectionTabs(props: {
? 'md:-mr-8 -mr-4 sm:-mr-6' ? 'md:-mr-8 -mr-4 sm:-mr-6'
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors' : 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
)} )}
active={currentSection.id} active={`#${currentSection.id}`}
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''} trailing={{
fade: true,
button: true,
className: children ? 'after:border-tint' : '',
}}
> >
<NavigationMenu.List <NavigationMenu.List
className={tcls( className={tcls(
@@ -128,7 +128,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
'lg:justify-center', 'lg:justify-center',
CONTAINER_STYLE, CONTAINER_STYLE,
'site-width-wide:max-w-screen-4xl', 'site-width-wide:max-w-screen-4xl',
'hydrated:transition-[max-width] duration-300', 'transition-[max-width] duration-300',
// Ensure the footer is display below the viewport even if the content is not enough // Ensure the footer is display below the viewport even if the content is not enough
withFooter && [ withFooter && [
@@ -141,79 +141,82 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<TableOfContents <TableOfContents
context={context} context={context}
header={ header={
withTopHeader ? null : ( <div
<div className={tcls(
className={tcls( 'pr-4',
'hidden', 'flex',
'pr-4', withTopHeader ? 'lg:hidden' : '',
'mt-2', 'grow-0',
'lg:flex', 'dark:shadow-light/1',
'grow-0', 'text-base/tight',
'dark:shadow-light/1', 'items-center'
'text-base/tight', )}
'items-center' >
)} <HeaderLogo context={context} />
> {variants.translations.length > 1 ? (
<HeaderLogo context={context} /> <TranslationsDropdown
{variants.translations.length > 1 ? ( context={context}
<TranslationsDropdown siteSpace={
context={context} variants.translations.find(
siteSpace={ (space) => space.id === siteSpace.id
variants.translations.find( ) ?? siteSpace
(space) => space.id === siteSpace.id }
) ?? siteSpace siteSpaces={variants.translations}
} className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
siteSpaces={variants.translations} />
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden" ) : null}
/> </div>
) : null}
</div>
)
} }
// Displays the search button and/or the space dropdown in the ToC // Displays the search button and/or the space dropdown in the ToC
// according to the header/variant settings. // according to the header/variant settings.
// E.g if there is no header, the search button will be displayed in the ToC. // E.g if there is no header, the search button will be displayed in the ToC.
innerHeader={ innerHeader={
<> !withTopHeader || variants.generic.length > 1 ? (
{!withTopHeader && ( <div
<div className="flex gap-2"> className={tcls(
<SearchContainer 'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
style={CustomizationSearchStyle.Subtle} variants.generic.length > 1 ? '' : 'max-lg:hidden'
withVariants={variants.generic.length > 1} )}
withSiteVariants={ >
visibleSections?.list.some( {!withTopHeader && (
(s) => <div className="flex gap-2 max-lg:hidden">
s.object === 'site-section' && <SearchContainer
s.siteSpaces.length > 1 style={CustomizationSearchStyle.Subtle}
) ?? false withVariants={variants.generic.length > 1}
} withSiteVariants={
withSections={withSections} visibleSections?.list.some(
section={visibleSections?.current} (s) =>
siteSpace={siteSpace} s.object === 'site-section' &&
siteSpaces={visibleSiteSpaces} s.siteSpaces.length > 1
className="max-lg:hidden" ) ?? false
viewport="desktop" }
withSections={withSections}
section={visibleSections?.current}
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
viewport="desktop"
/>
</div>
)}
{!withTopHeader && withSections && visibleSections && (
<SiteSectionList
className="hidden lg:block"
sections={encodeClientSiteSections(
context,
visibleSections
)}
/> />
</div> )}
)} {variants.generic.length > 1 ? (
{!withTopHeader && withSections && visibleSections && ( <SpacesDropdown
<SiteSectionList context={context}
className={tcls('hidden', 'lg:block')} siteSpace={siteSpace}
sections={encodeClientSiteSections( siteSpaces={variants.generic}
context, className="w-full px-3"
visibleSections />
)} ) : null}
/> </div>
)} ) : null
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3 py-2"
/>
) : null}
</>
} }
/> />
{children} {children}
@@ -32,7 +32,8 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
'my-2', 'my-2',
'border-tint-subtle', 'border-tint-subtle',
'sidebar-list-default:border-l', 'sidebar-list-default:border-l',
'sidebar-list-line:border-l' 'sidebar-list-line:border-l',
'break-anywhere'
)} )}
/> />
) : null ) : null
@@ -14,9 +14,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<li className="flex flex-col"> <li className="flex flex-col">
<div <div
className={tcls( className={tcls(
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6', '-top-4 sticky z-1 flex items-center gap-3 px-3',
'font-semibold text-xs uppercase tracking-wide', 'font-semibold text-xs uppercase tracking-wide',
'pb-3', // Add extra padding to make the header fade a bit nicer 'mt-2 pt-4 pb-3', // Add extra padding to make the header fade a bit nicer
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px. '-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
'mask-[linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation. 'mask-[linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation.
'bg-tint-base', 'bg-tint-base',
@@ -25,9 +25,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base', '[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary', 'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint', 'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
isFirst ? '-mt-6' : '' isFirst ? '-mt-4' : ''
)} )}
> >
<TOCPageIcon page={page} /> <TOCPageIcon page={page} />
@@ -1,91 +0,0 @@
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
type ComponentPropsWithoutRef,
} from 'react';
import { assert } from 'ts-essentials';
interface TOCScrollContainerContextType {
getContainer: (listener: (element: HTMLDivElement) => void) => () => void;
}
const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null);
function useTOCScrollContainerContext() {
const ctx = React.useContext(TOCScrollContainerContext);
assert(ctx);
return ctx;
}
/**
* Table of contents scroll container.
*/
export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) {
const ref = useRef<HTMLDivElement>(null);
const listeners = useRef<((element: HTMLDivElement) => void)[]>([]);
const getContainer: TOCScrollContainerContextType['getContainer'] = useCallback((listener) => {
if (ref.current) {
listener(ref.current);
return () => {};
}
listeners.current.push(listener);
return () => {
listeners.current = listeners.current.filter((l) => l !== listener);
};
}, []);
const value: TOCScrollContainerContextType = useMemo(() => ({ getContainer }), [getContainer]);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
listeners.current.forEach((listener) => listener(element));
return () => {
listeners.current = [];
};
}, []);
return (
<TOCScrollContainerContext.Provider value={value}>
<div ref={ref} data-testid="toc-scroll-container" {...props} />
</TOCScrollContainerContext.Provider>
);
}
// Offset to scroll the table of contents item by.
const TOC_ITEM_OFFSET = 100;
/**
* Scrolls the table of contents container to the page item when it's initially active.
*/
export function useScrollToActiveTOCItem(props: {
anchorRef: React.RefObject<HTMLAnchorElement | null>;
isActive: boolean;
}) {
const { isActive, anchorRef } = props;
const { getContainer } = useTOCScrollContainerContext();
useEffect(() => {
const anchor = anchorRef.current;
if (isActive && anchor) {
return getContainer((container) => {
if (isOutOfView(anchor, container)) {
container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET });
}
});
}
}, [isActive, getContainer, anchorRef]);
}
function isOutOfView(element: HTMLElement, container: HTMLElement) {
const tocItemTop = element.offsetTop;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
return (
tocItemTop < containerTop + TOC_ITEM_OFFSET ||
tocItemTop > containerBottom - TOC_ITEM_OFFSET
);
}
@@ -3,12 +3,16 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import type React from 'react'; import type React from 'react';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { PagesList } from './PagesList'; import { PagesList } from './PagesList';
import { TOCScrollContainer } from './TOCScroller';
import { TableOfContentsScript } from './TableOfContentsScript'; import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark'; import { Trademark } from './Trademark';
import { encodeClientTableOfContents } from './encodeClientTableOfContents'; import { encodeClientTableOfContents } from './encodeClientTableOfContents';
/**
* Sidebar container, responsible for setting the right dimensions and position for the sidebar.
*/
export async function TableOfContents(props: { export async function TableOfContents(props: {
context: GitBookSiteContext; context: GitBookSiteContext;
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
@@ -22,23 +26,38 @@ export async function TableOfContents(props: {
return ( return (
<> <>
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar. <SideSheet
side="left"
data-testid="table-of-contents" data-testid="table-of-contents"
id="table-of-contents" id="table-of-contents"
toggleClass="navigation-open"
withScrim={true}
withCloseButton={true}
className={tcls( className={tcls(
'group', 'group/table-of-contents',
'text-sm', 'text-sm',
'grow-0', 'grow-0',
'shrink-0', 'shrink-0',
'basis-full',
'lg:basis-72',
'lg:page-no-toc:basis-56',
'relative', 'w-72',
'z-1', 'lg:max-xl:w-56',
'lg:page-no-toc:w-56',
'4xl:page-no-toc:w-72',
'lg:page-no-toc:fixed',
'lg:page-no-toc:left-8',
'lg:page-no-toc:ml-[max(0px,calc((100vw-96rem)/2-20rem))]',
'max-lg:not-sidebar-filled:bg-tint-base',
'max-lg:not-sidebar-filled:border-r',
'border-tint-subtle',
'lg:flex!',
'lg:animate-none!',
'lg:sticky', 'lg:sticky',
'lg:mr-12', 'lg:mr-12',
// 'lg:z-0!',
'lg:z-20',
// Server-side static positioning // Server-side static positioning
'lg:top-0', 'lg:top-0',
@@ -60,30 +79,23 @@ export async function TableOfContents(props: {
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!', 'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!', 'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!',
'pt-4', 'pt-6 pb-4',
'pb-4',
'lg:sidebar-filled:pr-6', 'lg:sidebar-filled:pr-6',
'lg:page-no-toc:pr-0', 'lg:page-no-toc:pr-0',
'hidden', 'hidden',
'navigation-open:flex!', 'navigation-open:flex!',
'lg:flex', 'lg:flex',
'lg:page-no-toc:hidden',
'xl:page-no-toc:flex',
'lg:site-header-none:page-no-toc:flex',
'flex-col', 'flex-col',
'gap-4', 'gap-4',
'navigation-open:border-b',
'border-tint-subtle',
className className
)} )}
> >
{header && header} {header ? header : null}
<div // The actual sidebar, either shown with a filled bg or transparent. <div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls( className={tcls(
'lg:-ms-5', '-ms-5',
'relative flex grow flex-col overflow-hidden border-tint-subtle', 'relative flex min-h-0 grow flex-col border-tint-subtle',
'sidebar-filled:bg-tint-subtle', 'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle', 'theme-muted:bg-tint-subtle',
@@ -91,6 +103,7 @@ export async function TableOfContents(props: {
'[html.sidebar-filled.theme-muted_&]:bg-tint-base', '[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border', '[html.sidebar-filled.theme-gradient_&]:border',
'max-lg:sidebar-filled:border',
'page-no-toc:bg-transparent!', 'page-no-toc:bg-transparent!',
'page-no-toc:border-none!', 'page-no-toc:border-none!',
@@ -99,32 +112,33 @@ export async function TableOfContents(props: {
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-3xl' 'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-3xl'
)} )}
> >
{innerHeader ? ( {innerHeader ? innerHeader : null}
<div className="my-4 flex flex-col space-y-4 px-5 empty:hidden"> <ScrollContainer
{innerHeader} data-testid="toc-scroll-container"
</div> orientation="vertical"
) : null} contentClassName="flex flex-col p-2 gutter-stable"
<TOCScrollContainer // The scrollview inside the sidebar active="[data-active=true]"
className={tcls( leading={{
'flex grow flex-col p-2 pt-4', fade: true,
customization.trademark.enabled && 'lg:pb-20', button: {
'hide-scrollbar overflow-y-auto' className: '-mt-4',
)} },
}}
> >
<PagesList <PagesList
pages={pages} pages={pages}
isRoot={true} isRoot={true}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l" style="page-no-toc:hidden grow border-tint-subtle sidebar-list-line:border-l"
/> />
{customization.trademark.enabled ? ( </ScrollContainer>
<Trademark {customization.trademark.enabled ? (
context={context} <Trademark
placement={SiteInsightsTrademarkPlacement.Sidebar} context={context}
/> placement={SiteInsightsTrademarkPlacement.Sidebar}
) : null} />
</TOCScrollContainer> ) : null}
</div> </div>
</aside> </SideSheet>
<TableOfContentsScript /> <TableOfContentsScript />
</> </>
); );
@@ -8,7 +8,6 @@ import { tcls } from '@/lib/tailwind';
import { useCurrentPagePath } from '../hooks'; import { useCurrentPagePath } from '../hooks';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives'; import { Link, type LinkInsightsProps, type LinkProps } from '../primitives';
import { useScrollToActiveTOCItem } from './TOCScroller';
/** /**
* Client component for a page document to toggle its children and be marked as active. * Client component for a page document to toggle its children and be marked as active.
@@ -81,8 +80,6 @@ function LinkItem(
} }
) { ) {
const { isActive, href, insights, children, onActiveClick } = props; const { isActive, href, insights, children, onActiveClick } = props;
const anchorRef = useRef<HTMLAnchorElement>(null);
useScrollToActiveTOCItem({ anchorRef, isActive });
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isActive && onActiveClick) { if (isActive && onActiveClick) {
@@ -93,7 +90,7 @@ function LinkItem(
return ( return (
<Link <Link
ref={anchorRef} data-active={isActive}
href={href} href={href}
insights={insights} insights={insights}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
@@ -19,53 +19,11 @@ export function Trademark(props: {
return ( return (
<div <div
className={tcls( className={tcls(
'relative', 'mt-auto circular-corners:rounded-2xl rounded-lg straight-corners:rounded-none px-2 sidebar-default:pr-4 pb-2',
'z-2',
'lg:absolute',
'left-0',
'right-2',
'bottom-0',
'pointer-events-none',
'sidebar-filled:pl-2',
'sidebar-filled:pb-2',
'sidebar-filled:page-no-toc:p-0',
'bg-tint-base',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'rounded-lg',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'before:hidden',
'lg:before:block',
'before:content-[""]',
'before:absolute',
'before:inset-x-0',
'before:bottom-full',
'before:h-8',
'before:bg-linear-to-b',
'before:from-transparent',
'before:to-tint-base',
'sidebar-filled:before:to-tint-subtle',
'theme-muted:before:to-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
'page-no-toc:before:to-transparent!',
className className
)} )}
> >
<TrademarkLink <TrademarkLink {...rest} />
className="circular-corners:rounded-2xl rounded-lg straight-corners:rounded-none"
{...rest}
/>
</div> </div>
); );
} }
@@ -104,12 +62,12 @@ export function TrademarkLink(props: {
'sidebar-filled:px-3', 'sidebar-filled:px-3',
'lg:sidebar-filled:page-no-toc:px-5', 'lg:sidebar-filled:page-no-toc:px-5',
'lg:page-no-toc:bg-tint-base',
'hover:bg-tint', 'hover:bg-tint',
'hover:text-tint-strong', 'hover:text-tint-strong',
'ring-2', 'ring-1',
'lg:ring-1',
'ring-inset', 'ring-inset',
'ring-tint-subtle', 'ring-tint-subtle',
@@ -1,4 +1,3 @@
export { TableOfContents } from './TableOfContents'; export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList'; export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark'; export { Trademark } from './Trademark';
@@ -27,7 +27,11 @@ export function ThemeToggler() {
}; };
return ( return (
<ButtonGroup role="radiogroup" className="gap-2" combinedShape={false}> <ButtonGroup
role="radiogroup"
className="xl:page-no-toc:-m-2 gap-2 circular-corners:rounded-full bg-tint-base xl:page-no-toc:p-2"
combinedShape={false}
>
<ThemeButton <ThemeButton
active={mounted && theme === 'light'} active={mounted && theme === 'light'}
icon="sun-bright" icon="sun-bright"
@@ -66,11 +70,7 @@ function ThemeButton(props: {
aria-checked={active} aria-checked={active}
variant="blank" variant="blank"
size="default" size="default"
className={ active={active}
active
? 'bg-primary theme-muted:bg-primary-hover text-primary-strong ring-primary hover:bg-primary contrast-more:text-primary-strong contrast-more:ring-1 [html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover'
: ''
}
icon={icon} icon={icon}
iconOnly iconOnly
/> />
+3 -3
View File
@@ -9,9 +9,9 @@ export const HEADER_HEIGHT_DESKTOP = 64 as const;
* Style for the container to adapt between normal and full width. * Style for the container to adapt between normal and full width.
*/ */
export const CONTAINER_STYLE: ClassValue = [ export const CONTAINER_STYLE: ClassValue = [
'px-4', 'px-4 pl-[max(env(safe-area-inset-left),1rem)] pr-[max(env(safe-area-inset-right),1rem)]',
'sm:px-6', 'sm:px-6 sm:pl-[max(env(safe-area-inset-left),1.5rem)] sm:pr-[max(env(safe-area-inset-right),1.5rem)]',
'md:px-8', 'md:px-8 md:pl-[max(env(safe-area-inset-left),2rem)] md:pr-[max(env(safe-area-inset-right),2rem)]',
'max-w-screen-2xl', 'max-w-screen-2xl',
'mx-auto', 'mx-auto',
]; ];
@@ -28,7 +28,7 @@ export function HoverCard(
<RadixHoverCard.Portal> <RadixHoverCard.Portal>
<RadixHoverCard.Content <RadixHoverCard.Content
side={props.side ?? 'top'} side={props.side ?? 'top'}
className="pointer-events-none z-40 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto" className="pointer-events-none z-50 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
> >
<div <div
className={tcls( className={tcls(
@@ -4,7 +4,7 @@ import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import * as React from 'react'; import * as React from 'react';
import { useScrollListener } from '../hooks/useScrollListener'; import { useScrollListener } from '../hooks/useScrollListener';
import { Button } from './Button'; import { Button, type ButtonProps } from './Button';
/** /**
* A container that encapsulates a scrollable area with usability features. * A container that encapsulates a scrollable area with usability features.
@@ -17,17 +17,26 @@ export type ScrollContainerProps = {
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
leadingEdgeScrollClassName?: string;
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
trailingEdgeScrollClassName?: string;
/** The direction of the scroll container. */ /** The direction of the scroll container. */
orientation: 'horizontal' | 'vertical'; orientation: 'horizontal' | 'vertical';
/** Whether to fade out the edges of the container. */ leading?: {
fadeEdges?: ('leading' | 'trailing')[]; /** Whether to fade out the leading edge of the container. */
fade: boolean;
/** Whether to show a button to scroll back. */
button: boolean | ButtonProps;
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
className?: string;
};
trailing?: {
/** Whether to fade out the trailing edge of the container. */
fade: boolean;
/** Whether to show a button to scroll forward. */
button: boolean | ButtonProps;
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
className?: string;
};
/** The ID or ref of the active item to scroll to. */ /** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>; active?: string | React.RefObject<HTMLElement | null>;
@@ -39,10 +48,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
className, className,
contentClassName, contentClassName,
orientation, orientation,
fadeEdges = ['leading', 'trailing'],
active, active,
leadingEdgeScrollClassName, leading = { fade: true, button: true },
trailingEdgeScrollClassName, trailing = { fade: true, button: true },
...rest ...rest
} = props; } = props;
@@ -102,7 +110,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
return; return;
} }
const activeItem = const activeItem =
typeof active === 'string' ? document.getElementById(active) : active.current; typeof active === 'string'
? containerRef.current?.querySelector(active)
: active.current;
if (!activeItem || !container.contains(activeItem)) { if (!activeItem || !container.contains(activeItem)) {
return; return;
} }
@@ -138,28 +148,30 @@ export function ScrollContainer(props: ScrollContainerProps) {
return ( return (
<div <div
className={tcls( className={tcls(
'group/scroll-container relative flex overflow-hidden', 'group/scroll-container relative flex shrink grow',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
className, className,
scrollPosition > 0 ? leadingEdgeScrollClassName : '', scrollPosition > 0 ? leading?.className : '',
scrollPosition < scrollSize ? trailingEdgeScrollClassName : '' scrollPosition < scrollSize ? trailing?.className : ''
)} )}
{...rest} {...rest}
> >
{/* Scrollable content */} {/* Scrollable content */}
<div <div
className={tcls( className={tcls(
'flex shrink grow', 'flex flex-1 overflow-hidden',
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar', orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto', orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto',
fadeEdges.includes('leading') && scrollPosition > 0 leading.fade && scrollPosition > 0
? orientation === 'horizontal' ? orientation === 'horizontal'
? 'mask-l-from-[calc(100%-2rem)]' ? 'mask-l-from-[calc(100%-1rem)]'
: 'mask-t-from-[calc(100%-2rem)]' : 'mask-t-from-[calc(100%-1rem)]'
: '', : '',
fadeEdges.includes('trailing') && scrollPosition < scrollSize trailing.fade && scrollPosition < scrollSize
? orientation === 'horizontal' ? orientation === 'horizontal'
? 'mask-r-from-[calc(100%-2rem)]' ? 'mask-r-from-[calc(100%-1rem)]'
: 'mask-b-from-[calc(100%-2rem)]' : 'mask-b-from-[calc(100%-1rem)]'
: '', : '',
contentClassName contentClassName
)} )}
@@ -169,44 +181,52 @@ export function ScrollContainer(props: ScrollContainerProps) {
</div> </div>
{/* Scroll buttons back & forward */} {/* Scroll buttons back & forward */}
<Button {leading.button !== false ? (
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'} <Button
iconOnly icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
size="xsmall" iconOnly
variant="secondary" size="xsmall"
tabIndex={-1} variant="secondary"
className={tcls( tabIndex={-1}
'bg-tint-base!', onClick={scrollBack}
orientation === 'horizontal' label={tString(language, 'scroll_back')}
? '-translate-y-1/2! top-1/2 left-0 ml-2' {...(typeof leading.button === 'object' ? leading.button : {})}
: '-translate-x-1/2! top-0 left-1/2 mt-2', className={tcls(
'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]', 'z-10 bg-tint-base!',
scrollPosition > 0 orientation === 'horizontal'
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11' ? '-translate-y-1/2! top-1/2 left-0 ml-2'
: 'pointer-events-none' : '-translate-x-1/2! top-0 left-1/2 mt-2',
)} 'absolute z-10 not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
onClick={scrollBack} scrollPosition > 0
label={tString(language, 'scroll_back')} ? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
/> : 'pointer-events-none',
<Button typeof leading.button === 'object' ? leading.button.className : ''
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'} )}
iconOnly />
size="xsmall" ) : null}
variant="secondary" {trailing.button !== false ? (
tabIndex={-1} <Button
className={tcls( icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
'bg-tint-base!', iconOnly
orientation === 'horizontal' size="xsmall"
? '-translate-y-1/2! top-1/2 right-0 mr-2' variant="secondary"
: '-translate-x-1/2! bottom-0 left-1/2 mb-2', tabIndex={-1}
'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]', onClick={scrollFurther}
scrollPosition < scrollSize label={tString(language, 'scroll_further')}
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11' {...(typeof trailing.button === 'object' ? trailing.button : {})}
: 'pointer-events-none' className={tcls(
)} 'z-10 bg-tint-base!',
onClick={scrollFurther} orientation === 'horizontal'
label={tString(language, 'scroll_further')} ? '-translate-y-1/2! top-1/2 right-0 mr-2'
/> : '-translate-x-1/2! bottom-0 left-1/2 mb-2',
'absolute z-10 not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
scrollPosition < scrollSize
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none',
typeof trailing.button === 'object' ? trailing.button.className : ''
)}
/>
) : null}
</div> </div>
); );
} }
@@ -214,7 +234,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
/** /**
* Scroll to an element in a container. * Scroll to an element in a container.
*/ */
function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) { function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -229,6 +249,6 @@ function scrollToElementInContainer(element: HTMLElement, container: HTMLElement
(rect.left - containerRect.left) - (rect.left - containerRect.left) -
container.clientWidth / 2 + container.clientWidth / 2 +
rect.width / 2, rect.width / 2,
behavior: 'smooth', behavior: 'auto',
}); });
} }
@@ -0,0 +1,194 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { type ClassValue, tcls } from '@/lib/tailwind';
import React from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from './Button';
/**
* SideSheet - A slide-in panel component that can appear from the left or right side.
*
* Supports both controlled and uncontrolled modes:
* - Controlled: Provide both `open` and `onOpenChange` props. Parent manages state.
* - Uncontrolled: Omit `open` prop. Component manages its own state internally.
*/
export function SideSheet(
props: {
/** Which side the sheet slides in from */
side: 'left' | 'right';
/**
* Optional CSS class to monitor and sync with `document.body.classList`.
* When set, a MutationObserver watches for the class and syncs the sheet state accordingly.
* Adding this class opens the sheet, removing it closes it.
* Works in both controlled and uncontrolled modes.
*/
toggleClass?: string;
/**
* Modal behavior: true (always modal), false (never modal), or 'mobile' (modal only on mobile).
* Defaults to 'mobile'.
*/
modal?: true | false | 'mobile';
/**
* Controls visibility. If provided, component is controlled (parent manages state).
* If undefined, component is uncontrolled (manages its own state).
*/
open?: boolean;
/** Called when the open state changes. Receives the new state (true/false). Only used in controlled mode. */
onOpenChange?: (open: boolean) => void;
/** Show a backdrop overlay when modal */
withScrim?: boolean;
/** Show a close button when modal */
withCloseButton?: boolean;
} & React.HTMLAttributes<HTMLDivElement>
) {
const {
side,
children,
className,
toggleClass,
open: openState,
modal = 'mobile',
withScrim,
withCloseButton,
onOpenChange,
...rest
} = props;
const isMobile = useIsMobile();
const isModal = modal === 'mobile' ? isMobile : modal;
// Internal state for uncontrolled mode (only used when open prop is undefined)
const [open, setOpen] = React.useState(openState ?? false);
// Determine actual open state: controlled (from prop) or uncontrolled (from internal state)
const isOpen = openState !== undefined ? openState : open;
const handleClose = React.useCallback(() => {
if (openState !== undefined) {
// Controlled mode: parent manages state, notify via callback with new state
onOpenChange?.(false);
} else {
// Uncontrolled mode: update internal state and sync body class if needed
setOpen(false);
if (toggleClass) {
document.body.classList.remove(toggleClass);
}
}
}, [openState, onOpenChange, toggleClass]);
// Sync the sheet state with the body class if the toggleClass is set
React.useEffect(() => {
if (!toggleClass) {
return;
}
const callback = (mutationList: MutationRecord[]) => {
for (const mutation of mutationList) {
if (mutation.attributeName === 'class') {
const shouldBeOpen = document.body.classList.contains(toggleClass);
if (openState !== undefined) {
// Controlled mode: sync with parent's state
// Notify parent of state change via onOpenChange
if (shouldBeOpen !== openState) {
onOpenChange?.(shouldBeOpen);
}
} else {
// Uncontrolled mode: sync internal state with body class
setOpen(shouldBeOpen);
}
}
}
};
const observer = new MutationObserver(callback);
observer.observe(document.body, { attributes: true });
return () => observer.disconnect();
}, [toggleClass, openState, onOpenChange]);
return (
<>
{withScrim ? (
<SideSheetScrim
className={tcls(isModal && isOpen ? '' : 'hidden opacity-0 backdrop-blur-none')}
onClick={handleClose}
/>
) : null}
<aside
className={tcls(
'side-sheet',
isOpen
? side === 'left'
? 'hydrated:animate-enter-from-left'
: 'hydrated:animate-enter-from-right'
: side === 'left'
? 'hydrated:animate-exit-to-left'
: 'hydrated:animate-exit-to-right',
'fixed inset-y-0 z-41', // Above the side sheet scrim on z-40
side === 'left' ? 'left-0' : 'right-0',
withCloseButton ? 'max-w-[calc(100vw-5rem)]' : 'max-w-[calc(100vw-3rem)]',
isOpen ? '' : 'hidden',
className
)}
aria-expanded={isOpen}
aria-modal={isModal}
{...rest}
>
{children}
{withCloseButton ? (
<SideSheetCloseButton
className={tcls(
side === 'left' ? 'left-full ml-4' : 'right-full mr-4',
isModal && isOpen ? 'animate-blur-in' : 'hidden animate-blur-out'
)}
onClick={handleClose}
/>
) : null}
</aside>
</>
);
}
/** Backdrop overlay shown behind the modal sheet */
export function SideSheetScrim(props: { className?: ClassValue; onClick?: () => void }) {
const { className, onClick } = props;
return (
<div
id="side-sheet-scrim"
onClick={() => {
onClick?.();
}}
onKeyUp={(e) => {
if (e.key === 'Escape') {
onClick?.();
}
}}
className={tcls(
'fixed inset-0 z-40 items-start bg-tint-base/3 not-hydrated:opacity-0 starting:opacity-0 backdrop-blur-md starting:backdrop-blur-none transition-[opacity,display,backdrop-filter] transition-discrete duration-250 dark:bg-tint-base/6',
className
)}
/>
);
}
/** Close button displayed outside the sheet when modal */
export function SideSheetCloseButton(props: { className?: ClassValue; onClick?: () => void }) {
const { className, onClick } = props;
const language = useLanguage();
return (
<Button
icon="xmark"
variant="secondary"
size="default"
iconOnly
label={tString(language, 'close')}
className={tcls('absolute top-4 bg-tint-base! transition-discrete', className)}
onClick={() => {
onClick?.();
}}
/>
);
}
+13 -9
View File
@@ -328,10 +328,10 @@ const config: Config = {
blurOut: 'blurOut 200ms ease-in both', blurOut: 'blurOut 200ms ease-in both',
'blurOut-slow': 'blurOut 500ms ease-in both', 'blurOut-slow': 'blurOut 500ms ease-in both',
enterFromLeft: 'enterFromLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both', enterFromLeft: 'enterFromLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
enterFromRight: 'enterFromRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both', enterFromRight: 'enterFromRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToLeft: 'exitToLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both', exitToLeft: 'exitToLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both', exitToRight: 'exitToRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
heightIn: 'heightIn 200ms ease both', heightIn: 'heightIn 200ms ease both',
crawl: 'crawl 2s ease-in-out infinite', crawl: 'crawl 2s ease-in-out infinite',
@@ -474,18 +474,18 @@ const config: Config = {
}, },
enterFromRight: { enterFromRight: {
from: { opacity: '0', transform: 'translateX(50%)', display: 'none' }, from: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'block' }, to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
}, },
enterFromLeft: { enterFromLeft: {
from: { opacity: '0', transform: 'translateX(-50%)', display: 'none' }, from: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'block' }, to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
}, },
exitToRight: { exitToRight: {
from: { opacity: '1', transform: 'translateX(0)', display: 'block' }, from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
to: { opacity: '0', transform: 'translateX(50%)', display: 'none' }, to: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
}, },
exitToLeft: { exitToLeft: {
from: { opacity: '1', transform: 'translateX(0)', display: 'block' }, from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
to: { opacity: '0', transform: 'translateX(-50%)', display: 'none' }, to: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
}, },
scaleIn: { scaleIn: {
@@ -576,7 +576,11 @@ const config: Config = {
* Variant when the Table of Content navigation is open. * Variant when the Table of Content navigation is open.
*/ */
addVariant('navigation-open', 'body.navigation-open &'); addVariant('navigation-open', 'body.navigation-open &');
addVariant('chat-open', 'body:has(.ai-chat:not(.hidden)) &'); addVariant('chat-open', 'body:has(.ai-chat[aria-expanded="true"]) &');
addVariant(
'sheet-open',
'html:has(.side-sheet[aria-modal="true"][aria-expanded="true"]) &, &:has(.side-sheet[aria-modal="true"][aria-expanded="true"])'
);
/** /**
* Variant when a header is displayed. * Variant when a header is displayed.