Redesign search results (#4198)

Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
This commit is contained in:
Zeno Kapitein
2026-04-28 14:49:46 +02:00
committed by GitHub
parent 10d894ce19
commit 65558cfbb1
37 changed files with 927 additions and 333 deletions
+2 -1
View File
@@ -55,7 +55,8 @@
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
"usehooks-ts": "^3.1.1",
"vercel": "^50.26.1"
}
},
"patchedDependencies": {
@@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button to open/close the AI chat.
@@ -5,7 +5,6 @@ import {
SkeletonHeading,
SkeletonImage,
SkeletonParagraph,
SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind';
@@ -142,7 +141,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'file':
return <SkeletonHeading id={id} style={style} />;
case 'paragraph':
return <SkeletonSmall id={id} style={style} />;
return <SkeletonParagraph id={id} className={style} />;
case 'list-ordered':
case 'list-unordered':
case 'list-tasks':
@@ -153,7 +152,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'tabs':
case 'stepper-step':
case 'if':
return <SkeletonParagraph id={id} style={style} />;
return <SkeletonParagraph id={id} className={style} />;
case 'expandable':
case 'table':
case 'swagger':
@@ -173,7 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'drawing':
return <SkeletonImage id={id} style={style} />;
case 'updates':
return <SkeletonUpdate id={id} style={style} />;
return <SkeletonUpdate id={id} className={style} />;
case 'image':
case 'code-line':
case 'tabs-item':
@@ -61,6 +61,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
cursor={cursor}
error={error}
fetching={fetching}
fillHeight={true}
query={query}
results={results}
resultsId={resultsId}
@@ -79,6 +80,9 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
withAI={withSearchAI}
isOpen={true}
mode="frame"
resultsCount={results.length}
fetching={fetching}
showAsk={showAsk}
>
<SearchLiveResultsAnnouncer
count={results.length}
@@ -122,7 +122,7 @@ export function Header(props: {
'flex',
'grow-0',
'shrink-0',
'@2xl:basis-56',
'md:@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
@@ -130,15 +130,15 @@ export function Header(props: {
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'@2xl:grow-[0.8]',
'@4xl:basis-40',
'@2xl:max-w-[50%]',
'@4xl:max-w-lg',
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'@2xl:mr-auto',
'md:@2xl:mr-auto',
'order-last',
'@2xl:order-[unset]',
'md:@2xl:order-[unset]',
]
: ['order-last']
)}
@@ -96,7 +96,7 @@ export function SearchAskAnswer(props: { query: string; asEmbeddable?: boolean }
);
return (
<div className="flex min-h-full p-4">
<div className="flex grow p-4">
{askState?.type === 'answer' ? (
<React.Suspense fallback={loading}>
<TransitionAnswerBody
@@ -1,18 +1,23 @@
'use client';
import type { Assistant } from '@/components/AI';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Link } from '../primitives/Link';
import { t, tString, useLanguage } from '@/intl/client';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SearchResultItem } from './SearchResultItem';
import { useSearchLink } from './useSearch';
/**
* Sticky single-line bar at the bottom of the search frame.
* Clicking opens the AI assistant with the current query.
*/
export function SearchAskBar(props: { query: string; assistant: Assistant }) {
const { query, assistant } = props;
export function SearchAskBar(props: {
query: string;
assistant: Assistant;
active?: boolean;
withShortcut?: boolean;
onSelect?: () => void;
}) {
const { query, assistant, active = false, withShortcut = false, onSelect } = props;
const language = useLanguage();
const getSearchLinkProps = useSearchLink();
@@ -23,36 +28,28 @@ export function SearchAskBar(props: { query: string; assistant: Assistant }) {
open: assistant.mode === 'search',
},
() => {
onSelect?.();
assistant.open(query);
}
);
return (
<Link
<SearchResultItem
{...linkProps}
className={tcls(
'flex items-center gap-3 px-4 py-1.5',
'border-tint-subtle border-t bg-tint-subtle',
'text-sm text-tint',
'hover:bg-tint-hover hover:text-tint-strong',
'transition-colors',
'rounded-corners:rounded-b-md'
)}
active={active}
action={tString(language, 'ask')}
leadingIcon={assistant.icon}
data-testid="search-ask-question"
className="gutter-stable shrink-0 overflow-y-scroll rounded-none! border-tint-subtle border-t pr-5 pl-6"
>
<div className="size-4 shrink-0 text-tint-subtle">
{typeof assistant.icon === 'string' ? (
<Icon icon={assistant.icon as IconName} className="size-4" />
) : (
assistant.icon
)}
<div className="flex items-center justify-between gap-2">
<div className="line-clamp-1">
{t(language, 'ai_chat_ask_query', assistant.label, query)}
</div>
{withShortcut ? (
<KeyboardShortcut className="bg-tint-base" keys={['mod', 'i']} />
) : null}
</div>
<span className="truncate">
{tString(language, 'ask', '')}{' '}
<span className="font-medium text-tint-strong">{assistant.label}</span>{' '}
<span className="text-tint-subtle">"{query}"</span>
</span>
<Icon icon="chevron-right" className="ml-auto size-3 shrink-0 text-tint-subtle" />
</Link>
</SearchResultItem>
);
}
@@ -1,11 +1,15 @@
'use client';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { CustomizationSearchStyle } from '@gitbook/api';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { AIChatButton } from '../AIChat';
import { useIsMobile } from '../hooks/useIsMobile';
import { Popover } from '../primitives';
import { Button, Popover } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SideSheet } from '../primitives/SideSheet';
import { SearchFrame } from './SearchFrame';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
@@ -29,10 +33,14 @@ export function SearchContainer({
...searchProps
}: SearchContainerProps) {
const searchInputRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const language = useLanguage();
const usesSideSheet = useIsMobile(768);
const {
assistants,
askQuery,
askCount,
askInAssistant,
abort,
close,
cursor,
error,
@@ -68,6 +76,10 @@ export function SearchContainer({
'mod+i',
(e) => {
e.preventDefault();
if (state?.open && query && askCount > 0) {
askInAssistant();
return;
}
assistants[0]?.open();
},
{
@@ -88,81 +100,159 @@ export function SearchContainer({
};
}, [close]);
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
const searchResultsActiveDescendant = cursor !== null ? `${resultsId}-${cursor}` : undefined;
const visible =
viewport === 'desktop' ? !usesSideSheet : viewport === 'mobile' ? usesSideSheet : true;
const searchResultsActiveDescendant =
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
const isSearchOpen = Boolean(visible && (state?.open ?? false));
const shouldFillHeight = Boolean(query || showAsk);
const shouldShowSearchFrame = usesSideSheet
? Boolean(state?.open || state?.query || withAI)
: Boolean(state?.query || withAI);
const scopeControlNode =
searchProps.withVariants || searchProps.withSections ? (
<SearchScopeControl {...scopeControl} />
) : null;
const frameInput = (
<SearchInput
aria-activedescendant={searchResultsActiveDescendant}
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
mode="frame"
resultsCount={results.length}
fetching={fetching}
showAsk={showAsk}
>
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
);
const searchFrame = shouldShowSearchFrame ? (
<SearchFrame
askQuery={askQuery}
cursor={cursor}
error={error}
fetching={fetching}
input={usesSideSheet ? frameInput : undefined}
query={query}
results={results}
resultsId={resultsId}
resultsRef={resultsRef}
onResultSelect={abort}
showAsk={showAsk}
scopeControl={scopeControlNode}
fillHeight={usesSideSheet || shouldFillHeight}
/>
) : null;
return (
<>
<Popover
content={
// Only show content if there's a query or Ask is enabled
state?.query || withAI ? (
<SearchFrame
askQuery={askQuery}
cursor={cursor}
error={error}
fetching={fetching}
query={query}
results={results}
resultsId={resultsId}
resultsRef={resultsRef}
showAsk={showAsk}
scopeControl={
searchProps.withVariants || searchProps.withSections ? (
<SearchScopeControl {...scopeControl} />
) : null
}
/>
) : null
}
rootProps={{
open: Boolean(visible && (state?.open ?? false)),
modal: false,
}}
contentProps={{
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className:
'@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
onInteractOutside: (event) => {
// Don't close if clicking on the search input itself
if (searchInputRef.current?.contains(event.target as Node)) {
event.preventDefault();
return;
}
close();
},
sideOffset: 8,
collisionPadding: {
top: 16,
right: 16,
bottom: 32,
left: 16,
},
hideWhenDetached: true,
}}
triggerProps={{
asChild: true,
}}
>
<SearchInput
ref={searchInputRef}
aria-activedescendant={searchResultsActiveDescendant}
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
withAI={withSearchAI}
isOpen={state?.open ?? false}
{usesSideSheet ? (
<Button
data-testid="search-button"
icon="search"
variant="header"
size="medium"
iconOnly
label={
<div className="flex items-center gap-2">
{t(language, withSearchAI ? 'search_or_ask' : 'search')}
<KeyboardShortcut
keys={['mod', 'k']}
className="border-tint-11 text-tint-1"
/>
</div>
}
active={isSearchOpen}
onClick={open}
className={className}
onFocus={open}
aria-expanded={isSearchOpen}
aria-controls={resultsId}
/>
) : (
<Popover
content={searchFrame}
rootProps={{
open: isSearchOpen,
modal: false,
}}
contentProps={{
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className: tcls(
'@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-w-[min(var(--radix-popover-content-available-width),32rem)]',
shouldFillHeight
? 'h-[min(32rem,var(--radix-popover-content-available-height))]'
: 'max-h-[min(32rem,var(--radix-popover-content-available-height))]'
),
onInteractOutside: (event) => {
// Don't close if clicking on the search input itself
if (searchInputRef.current?.contains(event.target as Node)) {
event.preventDefault();
return;
}
close();
},
sideOffset: 8,
collisionPadding: {
top: 16,
right: 16,
bottom: 32,
left: 16,
},
hideWhenDetached: true,
}}
triggerProps={{
asChild: true,
}}
>
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
</Popover>
<SearchInput
ref={searchInputRef}
aria-activedescendant={searchResultsActiveDescendant}
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
className={className}
onFocus={open}
resultsCount={results.length}
fetching={fetching}
showAsk={showAsk}
>
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
</Popover>
)}
{usesSideSheet ? (
<SideSheet
side="right"
open={isSearchOpen}
onOpenChange={(open) => {
if (!open) {
close();
}
}}
modal
withOverlay
withCloseButton
className="mx-auto w-96 border-tint-subtle border-l bg-tint-base"
>
{searchFrame}
</SideSheet>
) : null}
{uiAssistants.map((assistant, index) => (
<AIChatButton
key={assistant.id}
@@ -9,6 +9,8 @@ import {
EmbeddableFrameMain,
EmbeddableFrameSidebar,
} from '../Embeddable/EmbeddableFrame';
import { Tooltip } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskBar } from './SearchAskBar';
@@ -21,11 +23,13 @@ export function SearchFrame(props: {
cursor: number | null;
error: boolean;
fetching: boolean;
fillHeight?: boolean;
input?: React.ReactNode;
query: string;
results: ResultType[];
resultsId: string;
resultsRef: React.Ref<SearchResultsRef>;
onResultSelect?: () => void;
scopeControl?: React.ReactNode;
showAsk: boolean;
sidebar?: React.ReactNode;
@@ -40,11 +44,13 @@ export function SearchFrame(props: {
dataTestId,
error,
fetching,
fillHeight = false,
input,
query,
results,
resultsId,
resultsRef,
onResultSelect,
scopeControl,
showAsk,
sidebar,
@@ -56,46 +62,116 @@ export function SearchFrame(props: {
return (
<SearchAskProvider value={searchAsk}>
<EmbeddableFrame
className={tcls('bg-tint-base from-transparent to-transparent', className)}
className={tcls(
'min-h-0 bg-tint-base from-transparent to-transparent',
fillHeight ? 'h-full' : '',
className
)}
>
{sidebar ? <EmbeddableFrameSidebar>{sidebar}</EmbeddableFrameSidebar> : null}
<EmbeddableFrameMain data-testid={dataTestId}>
<EmbeddableFrameMain className="min-h-0" data-testid={dataTestId}>
<div
className={tcls(
'pointer-events-none absolute inset-x-0 top-0 z-50 h-0.5 overflow-hidden',
fetching ? 'block animate-fade-in' : 'hidden animate-fade-out-slow'
)}
style={{
animationDelay: fetching ? '2s' : undefined,
}}
>
<div
className={tcls(
'h-full w-full origin-left animate-crawl bg-primary-solid'
)}
/>
</div>
{input ? (
<EmbeddableFrameHeader className="p-3 pb-0">{input}</EmbeddableFrameHeader>
) : null}
<React.Suspense fallback={null}>
<ScrollContainer orientation="vertical" contentClassName="p-3">
{showAsk ? (
<SearchAskAnswer query={askQuery} asEmbeddable={asEmbeddable} />
) : (
<SearchResults
ref={resultsRef}
query={query}
id={resultsId}
fetching={fetching}
results={results}
cursor={cursor}
error={error}
/>
<div
className={tcls(
'flex flex-col overflow-hidden',
fillHeight ? 'min-h-0 flex-1' : ''
)}
</ScrollContainer>
{!showAsk && query && assistants.length > 0
? assistants.map((assistant) => (
<SearchAskBar
key={assistant.id}
query={query}
assistant={assistant}
/>
))
: null}
{scopeControl && !showAsk ? (
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
{scopeControl}
>
<ScrollContainer
orientation="vertical"
className={tcls(fillHeight ? 'min-h-0 flex-1' : '')}
contentClassName={tcls(
'gutter-stable scroll-py-3 p-3',
fillHeight ? 'min-h-full' : ''
)}
>
{showAsk ? (
<SearchAskAnswer query={askQuery} asEmbeddable={asEmbeddable} />
) : (
<SearchResults
ref={resultsRef}
query={query}
id={resultsId}
fetching={fetching}
results={results}
cursor={cursor}
error={error}
onResultSelect={onResultSelect}
/>
)}
</ScrollContainer>
{!showAsk && query && assistants.length > 0
? assistants.map((assistant, index) => (
<SearchAskBar
key={assistant.id}
query={query}
assistant={assistant}
active={cursor === results.length + index}
withShortcut={assistant === assistants[0]}
onSelect={onResultSelect}
/>
))
: null}
<div
className={tcls(
'flex gap-2 border-tint-subtle border-t bg-tint-subtle px-4 py-1.5',
!scopeControl ? 'not-pointer-fine:hidden' : '',
showAsk ? 'hidden' : ''
)}
>
{scopeControl && !showAsk ? scopeControl : null}
<SearchFrameKeyboardHints />
</div>
) : null}
</div>
</React.Suspense>
</EmbeddableFrameMain>
</EmbeddableFrame>
</SearchAskProvider>
);
}
const SearchFrameKeyboardHints = () => {
return (
<div className="@container/keyboard-hint flex not-pointer-fine:hidden w-full items-center justify-end py-1.5">
{/* Compact view */}
<div className="flex @max-[6rem]/keyboard-hint:hidden @min-[12rem]/keyboard-hint:hidden items-center gap-2">
<Tooltip label="Navigate">
<div>
<KeyboardShortcut className="bg-tint-base" keys={['up', 'down']} />
</div>
</Tooltip>
/
<Tooltip label="Close">
<div>
<KeyboardShortcut className="bg-tint-base" keys={['esc']} />
</div>
</Tooltip>
</div>
{/* Normal view */}
<div className="flex @max-[12rem]/keyboard-hint:hidden items-center gap-2">
<KeyboardShortcut className="bg-tint-base" keys={['up', 'down']} />
Navigate
<KeyboardShortcut className="ml-1 bg-tint-base" keys={['esc']} />
Close
</div>
</div>
);
};
@@ -1,8 +1,9 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { Icon } from '@gitbook/icons';
import { AISearchIcon } from '../AIChat';
import { Input } from '../primitives';
interface SearchInputProps {
@@ -17,6 +18,9 @@ interface SearchInputProps {
className?: string;
children?: React.ReactNode;
mode?: 'header' | 'frame';
resultsCount: number;
fetching: boolean;
showAsk: boolean;
}
/**
@@ -34,6 +38,9 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
className,
children,
mode = 'header',
resultsCount,
fetching,
showAsk,
...rest
} = props;
const inputRef = useRef<HTMLInputElement>(null);
@@ -42,17 +49,26 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
const language = useLanguage();
useEffect(() => {
if (isOpen) {
if (!isOpen) {
inputRef.current?.blur();
return;
}
const focusInput = () => {
if (document.activeElement !== inputRef.current) {
// Refocus the input and move the caret to the end do this only once to avoid scroll jumps on every keystroke
inputRef.current?.focus({ preventScroll: true });
// Place cursor at the end of the input
inputRef.current?.setSelectionRange(value.length, value.length);
}
} else {
inputRef.current?.blur();
};
if (!isFrame) {
focusInput();
return;
}
}, [isOpen, value]);
const timeout = window.setTimeout(focusInput, 150);
return () => window.clearTimeout(timeout);
}, [isFrame, isOpen, value.length]);
return (
<div
@@ -76,14 +92,18 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
onFocus={onFocus}
onKeyDown={onKeyDown}
leading={
<Icon
icon="search"
className={
isFrame
? 'size-text-lg shrink-0 text-tint-subtle'
: 'size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint'
}
/>
showAsk ? (
<AISearchIcon />
) : (
<Icon
icon="search"
className={
isFrame
? 'size-text-lg shrink-0 text-tint-subtle'
: 'size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint'
}
/>
)
}
onValueChange={onChange}
value={value}
@@ -100,14 +120,21 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
}
}
trailing={
!showAsk && value && resultsCount > 0 ? (
<div className="mr-2 animate-blur-in text-sm text-tint-subtle">
{t(language, 'search_results_count', resultsCount.toString())}
</div>
) : undefined
}
keyboardShortcut={
isFrame
? undefined
: {
!value && !isFrame && !isOpen
? {
keys: ['mod', 'k'],
className:
'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
keys: isOpen ? ['esc'] : ['mod', 'k'],
'bg-tint-base site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
}
: undefined
}
{...rest}
type="text"
@@ -2,6 +2,7 @@ import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
import { SkeletonParagraph } from '../primitives';
import { Tooltip } from '../primitives';
import { Emoji } from '../primitives/Emoji/Emoji';
import { HighlightQuery } from './HighlightQuery';
@@ -17,10 +18,11 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
query: string;
item: PageItem;
active: boolean;
style?: React.CSSProperties;
},
ref: React.Ref<HTMLAnchorElement>
) {
const { query, item, active, ...rest } = props;
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
const bestSection = item.type === 'page' ? item.bestSection : undefined;
@@ -41,7 +43,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
) : icon ? (
<Icon icon={icon as IconName} className="size-4" />
) : (
<Icon icon="memo" className="size-4" />
<Icon icon="file-lines" className="size-4" />
);
const insights =
@@ -66,21 +68,62 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
leadingIcon={leadingIcon}
insights={insights}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{
...style,
}}
{...rest}
>
<Breadcrumbs breadcrumbs={item.breadcrumbs} />
<p className="line-clamp-2 font-semibold text-base text-tint-strong leading-snug">
<p className="line-clamp-1 font-semibold text-base text-tint-strong leading-snug">
<HighlightQuery query={query} text={item.title} />
</p>
{bestSection?.body ? (
<p className="line-clamp-1 text-xs leading-snug">
<HighlightQuery query={query} text={bestSection.body} />
</p>
) : 'description' in item && item.description ? (
<p className="line-clamp-1 text-xs leading-snug">
<HighlightQuery query={query} text={item.description} />
</p>
) : null}
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
item.type === 'local-page' && !bestSection?.body && !item.description
? '[[aria-busy=false]_&]:h-0'
: ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
{bestSection?.body ? (
<p
className="absolute inset-0 line-clamp-1 origin-left animate-blur-in text-sm"
style={{ animationDelay: style?.animationDelay }}
>
<HighlightQuery
query={query}
text={`${bestSection.title ? `${bestSection.title} · ` : ''}${bestSection.body}`}
/>
</p>
) : null}
{'description' in item && item.description ? (
<p
className={tcls(
'absolute inset-0 line-clamp-1 origin-left text-sm',
bestSection?.body ? 'hidden animate-blur-out' : ''
)}
style={{ animationDelay: style?.animationDelay }}
>
<HighlightQuery query={query} text={item.description} />
</p>
) : null}
{item.type === 'local-page' ? (
<SkeletonParagraph
size="small"
lines={1}
className={tcls(
'absolute inset-0 origin-left',
bestSection?.body || item.description
? 'hidden animate-blur-out'
: '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out'
)}
style={{ animationDelay: style?.animationDelay }}
/>
) : null}
</div>
</SearchResultItem>
);
});
@@ -149,6 +192,6 @@ const Breadcrumbs = (props: {
...crumbs.slice(-1),
]
: crumbs,
'text-tint/7 contrast-more:text-tint group-[.is-active]:text-tint mb-1 text-xxs uppercase leading-snug'
'text-tint/7 contrast-more:text-tint group-[.is-active]:text-tint text-xs'
);
};
@@ -1,7 +1,7 @@
import React from 'react';
import type { Assistant } from '@/components/AI';
import { t, tString, useLanguage } from '@/intl/client';
import { tString, useLanguage } from '@/intl/client';
import { SearchResultItem } from './SearchResultItem';
import { useSearchLink } from './useSearch';
@@ -11,19 +11,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
active: boolean;
recommended?: boolean;
assistant: Assistant;
style?: React.CSSProperties;
},
ref: React.Ref<HTMLAnchorElement>
) {
const { question, recommended = false, active, assistant, ...rest } = props;
const { question, recommended = false, active, assistant, style, ...rest } = props;
const language = useLanguage();
const getLinkProp = useSearchLink();
return (
<SearchResultItem
size={recommended ? 'small' : 'medium'}
size="small"
action={tString(language, 'ask', '')}
ref={ref}
data-testid={recommended ? 'search-recommended-question' : 'search-ask-question'}
data-testid="search-recommended-question"
scroll={false}
{...getLinkProp(
{
@@ -36,22 +37,10 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
}
)}
active={active}
leadingIcon={recommended ? 'search' : assistant.icon}
className={recommended ? 'pr-1.5' : ''}
leadingIcon="search"
{...rest}
>
{recommended ? (
question
) : (
<>
<div className="font-semibold text-base text-tint-strong leading-tight">
{t(language, 'search_ask', [question])}
</div>
<div className="text-sm text-tint-subtle">
{t(language, 'search_ask_description', assistant.label)}
</div>
</>
)}
{question}
</SearchResultItem>
);
});
@@ -11,10 +11,11 @@ export const SearchRecordResultItem = React.forwardRef(function SearchRecordResu
query: string;
item: ComputedRecordResult;
active: boolean;
style?: React.CSSProperties;
},
ref: React.Ref<HTMLAnchorElement>
) {
const { query, item, active, ...rest } = props;
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
const domain = getDomain(item.href);
@@ -41,6 +42,7 @@ export const SearchRecordResultItem = React.forwardRef(function SearchRecordResu
},
}}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{ ...style }}
{...rest}
>
<p className="line-clamp-2 font-semibold text-base text-tint-strong leading-snug">
@@ -13,6 +13,7 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
className?: string;
size?: 'small' | 'medium';
leadingIcon?: React.ReactNode;
disabled?: boolean;
} & LinkInsightsProps &
Omit<React.ComponentProps<typeof Link>, keyof LinkInsightsProps>,
ref: React.Ref<HTMLAnchorElement>
@@ -25,23 +26,25 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
leadingIcon,
size = 'medium',
action,
disabled,
...rest
} = props;
return (
<Link
ref={ref}
href={href}
href={href ?? '#'}
aria-disabled={disabled}
className={tcls(
'flex',
'items-center',
'gap-3',
'group',
'px-4',
size === 'small' ? 'py-1.5' : 'py-3',
'pl-3',
'pr-2',
'py-2',
'text-tint',
'hover:bg-tint',
'hover:text-tint-strong',
disabled ? ['pointer-events-none'] : ['hover:bg-tint', 'hover:text-tint-strong'],
'group',
'transition-colors',
'rounded-corners:rounded-md',
@@ -62,16 +65,17 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
)}
</div>
<div className="grow">{children}</div>
{active ? (
{disabled ? null : active ? (
<Button
label={action}
iconOnly
variant="primary"
size="small"
size="xsmall"
icon="arrow-turn-down-left"
className="size-7"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center text-tint-subtle">
<div className="flex size-7 shrink-0 items-center justify-center text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</div>
)}
@@ -1,21 +1,23 @@
'use client';
import assertNever from 'assert-never';
import { AnimatePresence, motion } from 'framer-motion';
import React from 'react';
import { useAI } from '@/components/AI';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Button, Loading } from '../primitives';
import { Button, SkeletonParagraph, SkeletonSmall } from '../primitives';
import { SearchPageResultItem } from './SearchPageResultItem';
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
import { SearchRecordResultItem } from './SearchRecordResultItem';
import { SearchResultItem } from './SearchResultItem';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
export interface SearchResultsRef {
select(): void;
select(): boolean;
}
type ResultType =
@@ -23,6 +25,21 @@ type ResultType =
| LocalPageResult
| { type: 'recommended-question'; id: string; question: string };
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
return `page:${item.id}`;
case 'page':
return `page:${item.pageId}`;
case 'record':
return `record:${item.id}`;
case 'recommended-question':
return `question:${item.id}`;
default:
return assertNever(item);
}
}
/**
* Fetch the results of the keyboard navigable elements to display for a query:
* - Recommended questions if no query is provided.
@@ -37,15 +54,31 @@ export const SearchResults = React.forwardRef(function SearchResults(
fetching: boolean;
cursor: number | null;
error: boolean;
onResultSelect?: () => void;
},
ref: React.Ref<SearchResultsRef>
) {
const { children, id, query, results, fetching, cursor, error } = props;
const { children, id, query, results, fetching, cursor, error, onResultSelect } = props;
const language = useLanguage();
const shouldAnimateResults = !query || fetching;
const previousCursor = React.useRef<number | null>(cursor);
const seenResultKeys = React.useRef(new Set<string>());
const lastQuery = React.useRef(query);
if (lastQuery.current !== query) {
lastQuery.current = query;
seenResultKeys.current.clear();
}
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
React.useEffect(() => {
for (const item of results) {
seenResultKeys.current.add(getResultKey(item));
}
}, [results]);
// Scroll to the active result.
React.useEffect(() => {
if (cursor === null || !refs.current[cursor]) {
@@ -55,15 +88,22 @@ export const SearchResults = React.forwardRef(function SearchResults(
refs.current[cursor]?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'instant',
});
}, [cursor]);
const shouldDisableLayoutAnimation = previousCursor.current !== cursor;
React.useEffect(() => {
previousCursor.current = cursor;
}, [cursor]);
const select = React.useCallback(() => {
if (cursor === null || !refs.current[cursor]) {
return;
return false;
}
refs.current[cursor]?.click();
return true;
}, [cursor]);
React.useImperativeHandle(
@@ -75,18 +115,19 @@ export const SearchResults = React.forwardRef(function SearchResults(
);
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error) {
return (
<div
<output
className={tcls(
'flex',
'grow',
'flex-col',
'items-center',
'justify-center',
'text-center',
'py-8',
'h-full',
'gap-4'
)}
>
@@ -99,27 +140,28 @@ export const SearchResults = React.forwardRef(function SearchResults(
>
{t(language, 'unexpected_error_retry')}
</Button>
</div>
</output>
);
}
const noResults = (
<div
<output
className={tcls(
'flex',
'grow',
'items-center',
'justify-center',
'text-center',
'py-8',
'h-full'
'animate-blur-in-slow'
)}
>
{t(language, 'search_no_results_for', query)}
</div>
</output>
);
return (
<div className={tcls('min-h-full')}>
<output className="flex grow flex-col" aria-busy={fetching}>
{children}
{results.length === 0 ? (
fetching ? null : query ? (
@@ -131,75 +173,183 @@ export const SearchResults = React.forwardRef(function SearchResults(
<>
<div
data-testid="search-results"
className="flex flex-col gap-y-1"
className="flex flex-col gap-1"
id={id}
role="listbox"
aria-live="polite"
>
{results.map((item, index) => {
const resultItemProps = {
'aria-posinset': index + 1,
'aria-setsize': results.length,
id: `${id}-${index}`,
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<SearchPageResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
query={query}
item={item}
active={index === cursor}
{...resultItemProps}
/>
);
<AnimatePresence initial={false} mode="popLayout">
{results.map((item, index) => {
const itemKey = getResultKey(item);
const shouldAnimateItem =
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
const resultItemProps = {
'aria-posinset': index + 1,
'aria-setsize': results.length,
id: `${id}-${index}`,
onClickCapture: () => onResultSelect?.(),
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<motion.div
layout="position"
transition={
shouldDisableLayoutAnimation
? { layout: { duration: 0 } }
: { duration: 0.3, ease: 'circInOut' }
}
key={itemKey}
>
<div
className={
shouldAnimateItem
? 'animate-blur-in-height'
: undefined
}
style={{
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}}
>
<SearchPageResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
query={query}
item={item}
active={index === cursor}
style={{
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}}
{...resultItemProps}
/>
</div>
</motion.div>
);
}
case 'recommended-question': {
if (!primaryAssistant) {
return null;
}
return (
<motion.div
className={
shouldAnimateItem
? 'animate-blur-in'
: undefined
}
style={
shouldAnimateItem
? {
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}
: undefined
}
key={itemKey}
>
<SearchQuestionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
question={item.question}
active={index === cursor}
assistant={primaryAssistant}
recommended
style={{
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}}
{...resultItemProps}
/>
</motion.div>
);
}
case 'record': {
return (
<motion.div
layout="position"
transition={
shouldDisableLayoutAnimation
? { layout: { duration: 0 } }
: { duration: 0.3, ease: 'circInOut' }
}
key={itemKey}
>
<div
className={
shouldAnimateItem
? 'animate-blur-in-height'
: undefined
}
style={{
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}}
>
<SearchRecordResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={itemKey}
query={query}
item={item}
active={index === cursor}
style={{
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}}
{...resultItemProps}
/>
</div>
</motion.div>
);
}
default:
assertNever(item);
}
case 'recommended-question': {
return (
<SearchQuestionResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
question={item.question}
active={index === cursor}
assistant={assistants[0]!}
recommended
{...resultItemProps}
/>
);
}
case 'record': {
return (
<SearchRecordResultItem
ref={(ref) => {
refs.current[index] = ref;
}}
key={item.id}
query={query}
item={item}
active={index === cursor}
{...resultItemProps}
/>
);
}
default:
assertNever(item);
}
})}
})}
</AnimatePresence>
</div>
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{fetching ? (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-6')}>
<Loading className={tcls('w-6', 'text-tint/6')} />
<div
className={tcls(
results.length > 0 ? 'mt-1' : '',
'flex animate-blur-in flex-col gap-1'
)}
style={{ animationDelay: `${results.length * 25}ms` }}
>
<SearchResultsSkeleton items={Math.max(3, 5 - results.length)} />
</div>
) : null}
</div>
</output>
);
});
const SearchResultsSkeleton = (props: { items: number }) => {
const { items } = props;
const skeletonKeys = Array.from({ length: items }, (_, index) => `skeleton:${index}`);
return (
<>
{skeletonKeys.map((key, index) => (
<SearchResultItem
key={key}
active={false}
href="#"
action=""
disabled
data-testid="search-page-result"
leadingIcon={
<SkeletonSmall
className="size-4"
style={{ animationDelay: `${index * 0.3}s` }}
/>
}
>
<SkeletonParagraph size="small" start={index * 3} />
</SearchResultItem>
))}
</>
);
};
@@ -22,7 +22,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
const state = useSearchState();
if (!state) {
if (!state?.open) {
return null;
}
@@ -31,7 +31,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
const variantScopeIsExtended = ['extended', 'all'].includes(state.scope);
return (
<div className="flex items-center gap-1">
<div className="flex items-center">
<SearchScopeTitle />
{withSections ? (
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
@@ -62,11 +62,15 @@ function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const { query, results, resultsRef } = props;
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
results,
resultCount: results.length,
totalCount: results.length + askCount,
});
const onInputKeyDown = React.useCallback(
@@ -79,10 +83,18 @@ function useSearchKeyboardNavigation(props: {
moveCursorBy(1);
} else if (event.key === 'Enter') {
event.preventDefault();
resultsRef.current?.select();
if (cursor !== null && cursor >= results.length) {
onAskSelect(cursor - results.length);
return;
}
if (resultsRef.current?.select()) {
// Stop any in-flight search request only when activating a result.
abort();
}
}
},
[moveCursorBy, resultsRef]
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
);
return {
@@ -179,7 +191,7 @@ export function useSearchController(props: SearchBaseProps) {
language: siteSpace.space.language,
});
const { results, fetching, error } = useSearchResults({
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(state?.query || withAI),
query: normalizedQuery,
@@ -195,18 +207,45 @@ export function useSearchController(props: SearchBaseProps) {
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
const searchResultsId = `search-results-${React.useId()}`;
const askInAssistant = React.useCallback(
(assistantIndex = 0) => {
const assistant = assistants[assistantIndex];
if (!assistant || !normalizedQuery) {
return;
}
abort();
assistant.open(normalizedQuery);
setSearchState({
ask: normalizedQuery,
query: null,
scope: state?.scope ?? 'default',
open: assistant.mode === 'search',
});
},
[abort, assistants, normalizedQuery, setSearchState, state?.scope]
);
const askCount = normalizedQuery && !showAsk ? assistants.length : 0;
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
resultsRef,
abort,
askCount,
onAskSelect: askInAssistant,
});
return {
assistants,
askQuery: normalizedAsk,
askCount,
askInAssistant,
cursor,
error,
fetching,
abort,
open: onOpen,
close: onClose,
query: normalizedQuery,
@@ -84,6 +84,10 @@ export function useSearchResults(props: {
error: boolean;
}>({ results: [], fetching: false, error: false });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
const abortRef = React.useRef<(() => void) | null>(null);
const { assistants } = useAI();
const withAI = assistants.length > 0;
@@ -227,10 +231,17 @@ export function useSearchResults(props: {
}
}, 200);
abortRef.current = () => {
cancelled = true;
clearTimeout(timeout);
abortController.abort();
};
return () => {
cancelled = true;
clearTimeout(timeout);
abortController.abort();
abortRef.current = null;
};
}, [
query,
@@ -245,6 +256,12 @@ export function useSearchResults(props: {
asEmbeddable,
]);
const abort = React.useCallback(() => {
abortRef.current?.();
abortRef.current = null;
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
}, []);
// Merge local and remote results.
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
@@ -272,6 +289,7 @@ export function useSearchResults(props: {
results,
fetching: remoteState.fetching,
error: remoteState.error,
abort,
};
}
@@ -1,9 +1,12 @@
import React from 'react';
import type { ResultType } from './useSearchResults';
export function useSearchResultsCursor(props: { query: string; results: ResultType[] }) {
export function useSearchResultsCursor(props: {
query: string;
resultCount: number;
totalCount: number;
}) {
const [cursor, setCursor] = React.useState<number | null>(null);
const { query, results } = props;
const { query, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
@@ -13,22 +16,48 @@ export function useSearchResultsCursor(props: { query: string; results: ResultTy
}, [query]);
React.useEffect(() => {
if (results.length > 0) {
// Auto-focus the first result
setCursor(0);
if (totalCount === 0) {
return;
}
}, [results]);
setCursor((prev) => {
if (resultCount > 0) {
return 0;
}
return prev ?? 0;
});
}, [resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
if (prev === null) {
return prev;
}
if (totalCount === 0) {
return null;
}
return Math.min(prev, totalCount - 1);
});
}, [totalCount]);
const moveBy = React.useCallback(
(delta: number) => {
setCursor((prev) => {
if (totalCount === 0) {
return null;
}
if (prev === null) {
return 0;
}
return Math.max(Math.min(prev + delta, results.length - 1), 0);
return Math.max(Math.min(prev + delta, totalCount - 1), 0);
});
},
[results]
[totalCount]
);
return {
@@ -22,7 +22,7 @@ export function SitePageSkeleton() {
>
<div className={tcls('flex-1', CONTENT_STYLE)}>
<SkeletonHeading style={tcls('mb-8')} />
<SkeletonParagraph style={tcls('mb-4')} />
<SkeletonParagraph className={tcls('mb-4')} />
</div>
</div>
);
@@ -14,7 +14,7 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
return (
<div
className={tcls(
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex'
'shortcut flex not-pointer-fine:hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong'
)}
>
{keys.map((key, index) => {
@@ -33,6 +33,14 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
case 'enter':
element = <Icon icon="arrow-turn-down-left" className="size-[.9em]" />;
break;
case 'up':
element = <Icon icon="arrow-up" className="size-[.9em]" />;
break;
case 'down':
element = <Icon icon="arrow-down" className="size-[.9em]" />;
break;
}
return (
<kbd
@@ -20,7 +20,7 @@ export function Popover(props: {
collisionPadding={contentProps?.collisionPadding ?? 16}
sideOffset={contentProps?.sideOffset ?? 4}
className={tcls(
'z-50 max-h-(--radix-popover-content-available-height) max-w-xs animate-scale-in overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint px-4 py-3 text-sm text-tint depth-subtle:shadow-xl shadow-tint-12/4 outline-hidden ring-1 ring-tint transition-all empty:hidden data-[state="closed"]:animate-scale-out motion-reduce:transition-none dark:shadow-tint-1/6',
'z-50 max-h-(--radix-popover-content-available-height) max-w-xs animate-scale-in overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl bg-tint px-4 py-3 text-sm text-tint depth-subtle:shadow-xl shadow-tint-12/4 outline-hidden ring-1 ring-tint transition-all empty:hidden data-[state="closed"]:animate-scale-out motion-reduce:transition-none dark:shadow-tint-1/6',
contentProps?.className
)}
style={{
@@ -4,6 +4,7 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { type ClassValue, tcls } from '@/lib/tailwind';
import React from 'react';
import { createPortal } from 'react-dom';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from './Button';
@@ -61,6 +62,7 @@ export function SideSheet(
const isMobile = useIsMobile();
const isModal = modal === 'mobile' ? isMobile : modal;
const asideRef = React.useRef<HTMLElement>(null);
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
// Internal state for uncontrolled mode (only used when open prop is undefined)
const [open, setOpen] = React.useState(openState ?? false);
@@ -72,6 +74,15 @@ export function SideSheet(
const [shouldHide, setShouldHide] = React.useState(!isOpen);
// Track if component has been opened to prevent initial animation
React.useEffect(() => {
if (isModal) {
setPortalTarget(document.body);
return;
}
setPortalTarget(null);
}, [isModal]);
React.useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
if (isOpen) {
@@ -191,7 +202,7 @@ export function SideSheet(
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isModal, isOpen]);
return (
const content = (
<>
{withOverlay ? (
<SideSheetOverlay
@@ -206,7 +217,7 @@ export function SideSheet(
'side-sheet',
'fixed inset-y-0 z-41', // Above the side sheet overlay on z-40
side === 'left' ? 'left-0' : 'right-0',
withCloseButton ? 'max-w-[calc(100%-4rem)]' : 'max-w-[calc(100%-3rem)]',
withCloseButton ? 'max-w-[calc(100%-4.5rem)]' : 'max-w-[calc(100%-3rem)]',
isOpen
? side === 'left'
? 'hydrated:animate-enter-from-left'
@@ -240,6 +251,12 @@ export function SideSheet(
</aside>
</>
);
if (isModal && portalTarget) {
return createPortal(content, portalTarget);
}
return content;
}
/** Backdrop overlay shown behind the modal sheet */
@@ -7,13 +7,49 @@ import { LoadingPane } from './LoadingPane';
* Placeholder to be used when a content is not yet loaded (in a React.Suspense boundary).
* It's used when streaming the content of a page.
*/
export function SkeletonParagraph(props: { id?: string; style?: ClassValue }) {
const { id, style } = props;
export function SkeletonParagraph(props: {
lines?: number;
id?: string;
size?: 'xsmall' | 'small' | 'medium';
start?: number;
className?: ClassValue;
style?: React.CSSProperties;
}) {
const { lines = 3, id, size = 'medium', start = 0, className, style } = props;
const lineHeight = size === 'small' ? 'h-5' : 'h-6';
const itemHeight = size === 'small' ? 'h-3' : 'h-4';
const wordGap = size === 'small' ? 'gap-1' : 'gap-2';
return (
<div id={id} role="status" aria-busy className="skeleton-paragraph">
<LoadingPane
style={['rounded-md', 'h-[calc(15rem-1px)]', 'max-w-[calc(48rem-1px)]', style]}
/>
<div
className={tcls('flex flex-col', className)}
id={id}
role="status"
aria-busy
style={style}
>
{Array.from({ length: lines }).map((_, line) => {
const itemsPerLine = (line % 2) + 2;
return (
<div
key={line + start}
className={tcls('flex items-center', lineHeight, wordGap)}
style={{ width: `${(4 - ((line + start) % 4)) * 8 + 62}%` }}
>
{Array.from({ length: itemsPerLine }).map((_, item) => (
<LoadingItem
key={item}
className={itemHeight}
style={{
flexGrow: ((line + start + item) % 3) + 1,
animationDelay: `${(item + line + start) * 0.1}s`,
}}
/>
))}
</div>
);
})}
</div>
);
}
@@ -62,29 +98,45 @@ export function SkeletonCard(props: { id?: string; style?: ClassValue }) {
/**
* Placeholder when loading small elements
*/
export function SkeletonSmall(props: { id?: string; style?: ClassValue }) {
const { id, style } = props;
return (
<div id={id} role="status" aria-busy className={tcls(style)}>
<LoadingPane tile={12} style={['rounded-md', 'h-[35px]', 'max-w-[calc(48rem-1px)]']} />
</div>
);
export function SkeletonSmall(
props: { id?: string; className?: ClassValue } & React.ComponentProps<'div'>
) {
const { id, className, ...rest } = props;
return <LoadingItem role="status" aria-busy id={id} className={tcls(className)} {...rest} />;
}
/**
* Placeholder when loading an Update block
*/
export function SkeletonUpdate(props: { id?: string; style?: ClassValue }) {
const { id, style } = props;
export function SkeletonUpdate(props: { id?: string; className?: ClassValue }) {
const { id, className } = props;
return (
<div
id={id}
role="status"
aria-busy
className={tcls('flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20', style)}
className={tcls(
'flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20',
className
)}
>
<SkeletonSmall id={id} style={['w-48', style]} />
<SkeletonSmall id={id} className={tcls('w-48', className)} />
<LoadingPane tile={96} delay={0} style={['rounded-md', 'w-full']} />
</div>
);
}
function LoadingItem(props: React.ComponentProps<'div'>): React.ReactNode {
const { className, ...rest } = props;
return (
<div
className={tcls(
'animate-[blurIn_500ms_ease-out_both,pulse_2s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2',
className
)}
{...rest}
/>
);
}
@@ -30,6 +30,7 @@ export const de = {
search_scope_variant_all: 'Alle Inhalte',
search_scope_variant_all_description: 'Jede verfügbare Variante',
ask: 'Fragen',
ai_chat_ask_query: 'An ${1} fragen: "${2}"',
search_ask: 'Fragen "${1}"',
search_ask_description: 'Finden Sie die Antwort mit ${1}',
search_ask_sources: 'Quellen',
@@ -134,6 +134,7 @@ export const en = {
ai_chat_tools_mcp_tool: 'Called ${1}',
ai_chat_ask: 'Ask ${1}',
ai_chat_ask_about_page: 'Ask ${1} about this page',
ai_chat_ask_query: 'Ask ${1} "${2}"',
copy_for_llms: 'Copy for LLMs',
copy_page_markdown: 'Copy page as Markdown for LLMs',
copy_page: 'Copy page',
@@ -32,6 +32,7 @@ export const es: TranslationLanguage = {
search_scope_variant_all: 'Todo el contenido',
search_scope_variant_all_description: 'Cada variante disponible',
ask: 'Preguntar',
ai_chat_ask_query: 'Preguntar a ${1} "${2}"',
search_ask: 'Preguntar "${1}"',
search_ask_description: 'Encuentra la respuesta con ${1}',
search_ask_sources: 'Fuentes',
@@ -30,6 +30,7 @@ export const fr = {
search_scope_variant_all: 'Tout le contenu',
search_scope_variant_all_description: 'Chaque variante disponible',
ask: 'Une question ?',
ai_chat_ask_query: 'Demander à ${1} « ${2} »',
search_ask: 'Demander « ${1} »',
search_ask_description: 'Trouvez la réponse avec ${1}',
search_ask_sources: 'Sources',
@@ -32,6 +32,7 @@ export const it: TranslationLanguage = {
search_scope_variant_all: 'Tutti i contenuti',
search_scope_variant_all_description: 'Ogni variante disponibile',
ask: 'Chiedi',
ai_chat_ask_query: 'Chiedi a ${1} "${2}"',
search_ask: 'Chiedi "${1}"',
search_ask_description: 'Trova la risposta con ${1}',
search_ask_sources: 'Fonti',
@@ -32,6 +32,7 @@ export const ja: TranslationLanguage = {
search_scope_variant_all: 'すべてのコンテンツ',
search_scope_variant_all_description: '利用可能なすべてのバリエーション',
ask: '質問する',
ai_chat_ask_query: '"${2}" を ${1} に質問する',
search_ask: '"${1}" を質問する',
search_ask_description: '${1}で答えを見つける',
search_ask_sources: '情報源',
@@ -32,6 +32,7 @@ export const ko: TranslationLanguage = {
search_scope_variant_all: '전체 콘텐츠',
search_scope_variant_all_description: '사용 가능한 모든 변형',
ask: '질문',
ai_chat_ask_query: '${1}에게 "${2}" 질문하기',
search_ask: '"${1}" 질문하기',
search_ask_description: '${1}로 답변 찾기',
search_ask_sources: '출처',
@@ -32,6 +32,7 @@ export const nl: TranslationLanguage = {
search_scope_variant_all: 'Alle inhoud',
search_scope_variant_all_description: 'Elke beschikbare variant',
ask: 'Vragen',
ai_chat_ask_query: 'Vraag ${1} "${2}"',
search_ask: 'Vraag "${1}"',
search_ask_description: 'Vind het antwoord met ${1}',
search_ask_sources: 'Bronnen',
@@ -32,6 +32,7 @@ export const no: TranslationLanguage = {
search_scope_variant_all: 'Alt innhold',
search_scope_variant_all_description: 'Hver tilgjengelig variant',
ask: 'Spør',
ai_chat_ask_query: 'Spør ${1} "${2}"',
search_ask: 'Spør "${1}"',
search_ask_description: 'Finn svaret med ${1}',
search_ask_sources: 'Kilder',
@@ -30,6 +30,7 @@ export const pt_br = {
search_scope_variant_all: 'Todo o conteúdo',
search_scope_variant_all_description: 'Cada variante disponível',
ask: 'Perguntar',
ai_chat_ask_query: 'Perguntar a ${1} "${2}"',
search_ask: 'Perguntar "${1}"',
search_ask_description: 'Encontre a resposta com ${1}',
search_ask_sources: 'Fontes',
@@ -30,6 +30,7 @@ export const ru = {
search_scope_variant_all: 'Весь контент',
search_scope_variant_all_description: 'Каждый доступный вариант',
ask: 'Спросить',
ai_chat_ask_query: 'Спросить у ${1} "${2}"',
search_ask: 'Спросить "${1}"',
search_ask_description: 'Найти ответ с помощью ${1}',
search_ask_sources: 'Источники',
@@ -32,6 +32,7 @@ export const zh: TranslationLanguage = {
search_scope_variant_all: '所有内容',
search_scope_variant_all_description: '每个可用的变体',
ask: '询问',
ai_chat_ask_query: '向${1}询问"${2}"',
search_ask: '询问"${1}"',
search_ask_description: '利用${1}找到答案',
search_ask_sources: '来源',
+60 -23
View File
@@ -314,23 +314,29 @@ const config: Config = {
'fadeIn-slow': 'fadeIn 500ms ease both',
fadeOut: 'fadeOut 200ms ease both',
'fadeOut-slow': 'fadeOut 500ms ease both',
appear: 'appear 200ms ease both allow-discrete',
appear: 'fadeIn 1ms ease both',
blurIn: 'blurIn 200ms ease-out both',
blurInSlow: 'blurIn 500ms ease-out both',
blurOut: 'blurOut 200ms ease-in both',
blurOutSlow: 'blurOut 500ms ease-in both',
blurInDisplay: 'blurInDisplay 200ms ease-out both',
blurInDisplaySlow: 'blurInDisplay 500ms ease-out both',
blurOutDisplay: 'blurOutDisplay 200ms ease-in both',
blurOutDisplaySlow: 'blurOutDisplay 500ms ease-in both',
blurIn: 'blurIn 200ms both',
blurInSlow: 'blurIn 500ms both',
blurOut: 'blurOut 200ms both',
blurOutSlow: 'blurOut 500ms both',
blurInDisplay: 'blurInDisplay 200ms both',
blurInDisplaySlow: 'blurInDisplay 500ms both',
blurOutDisplay: 'blurOutDisplay 200ms both',
blurOutDisplaySlow: 'blurOutDisplay 500ms both',
blurInHeight: 'heightIn 200ms ease-out both, blurIn 200ms 100ms both',
blurInHeightSlow: 'heightIn 500ms ease-out both, blurIn 500ms 300ms both',
blurOutHeight: 'blurOut 200ms both, heightOut 200ms 100ms ease-out both',
blurOutHeightSlow: 'blurOut 500ms both, heightOut 500ms 300ms ease-out both',
enterFromLeft: 'enterFromLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
enterFromRight: 'enterFromRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
exitToLeft: 'exitToLeft 300ms 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 500ms ease both',
heightOut: 'heightOut 500ms ease both',
crawl: 'crawl 2s ease-in-out infinite',
},
keyframes: {
@@ -494,28 +500,28 @@ const config: Config = {
to: { opacity: '0', transform: 'rotateX(-10deg) scale(0.95)' },
},
blurIn: {
from: { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
to: { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
'0%': { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
'100%': { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
},
blurOut: {
from: { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
to: { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
'0%': { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
'100%': { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
},
blurInDisplay: {
from: {
'0%': {
filter: 'blur(6px)',
opacity: '0',
transform: 'scale(0.95)',
display: 'none',
},
to: {
'100%': {
filter: 'blur(0px)',
opacity: '1',
transform: 'scale(1)',
},
},
blurOutDisplay: {
from: {
'0%': {
filter: 'blur(0px)',
opacity: '1',
transform: 'scale(1)',
@@ -534,16 +540,25 @@ const config: Config = {
heightIn: {
from: {
height: '0',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
marginTop: '0',
marginBottom: '0',
gap: '0',
overflow: 'hidden',
},
to: { height: 'max-content' },
},
heightOut: {
from: { height: 'max-content' },
to: {
height: '0',
paddingTop: '0',
paddingBottom: '0',
marginTop: '0',
marginBottom: '0',
gap: '0',
},
to: { height: 'max-content', opacity: '1' },
},
heightOut: {
from: { height: 'max-content', opacity: '1' },
to: { height: '0', opacity: '0', marginTop: '0', marginBottom: '0', gap: '0' },
},
crawl: {
'0%': {
@@ -607,6 +622,28 @@ const config: Config = {
'4xl': '2144px',
},
},
animationDuration: {
'100': '100ms',
'200': '200ms',
'300': '300ms',
'500': '500ms',
'1000': '1000ms',
'1500': '1500ms',
'2000': '2000ms',
'2500': '2500ms',
'3000': '3000ms',
},
animationDelay: {
'100': '100ms',
'200': '200ms',
'300': '300ms',
'500': '500ms',
'1000': '1000ms',
'1500': '1500ms',
'2000': '2000ms',
'2500': '2500ms',
'3000': '3000ms',
},
},
opacity: opacity(),
screens: {