mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-08 14:20:55 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 365f60d2ca | |||
| c716df39ba | |||
| 8ee84f130f | |||
| aa86711d5a | |||
| 5347917a47 | |||
| 43e8deca0f | |||
| 4cdab37eac | |||
| 8a887fd890 | |||
| 2f6ea5b1f0 | |||
| dcd03554bd | |||
| 9281b5e3b7 | |||
| ec0b6075b0 | |||
| e372dae552 | |||
| e329b41764 | |||
| c8956720d0 | |||
| 843635225d | |||
| f78e9ca34e | |||
| 72a06718e2 | |||
| 0a7979ede2 | |||
| fb49a27cc1 | |||
| 555a1da3ab | |||
| b9f9623970 | |||
| a7b80c3c4f | |||
| e05f262f46 | |||
| f53e0427e4 | |||
| 0d44191666 | |||
| 9e669f162a |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add sidesheet component, use it for TOC and AIChat
|
||||||
+2
-1
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
+5
-1
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user