mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Redesign search results (#4198)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
This commit is contained in:
+2
-1
@@ -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: '来源',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user