mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 10:03:31 +00:00
Scope search across sections and variants (#3640)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Scope search across sections and variants
|
||||
@@ -137,7 +137,7 @@ export function useAI(): AIContext {
|
||||
setSearchState((prev) => ({
|
||||
ask: null, // Reset ask as we assume the assistant will handle it
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
assistant.open(query);
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -159,7 +159,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -374,7 +374,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
@@ -435,7 +435,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
@@ -120,9 +120,28 @@ export function Header(props: {
|
||||
>
|
||||
<SearchContainer
|
||||
style={customization.styling.search}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) => s.space.language === siteSpace.space.language
|
||||
).length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={!!sections}
|
||||
section={
|
||||
sections
|
||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||
encodeClientSiteSections(context, sections).current
|
||||
: undefined
|
||||
}
|
||||
spaceTitle={siteSpace.title}
|
||||
siteSpaceId={siteSpace.id}
|
||||
siteSpaceIds={siteSpaces
|
||||
.filter((s) => s.space.language === siteSpace.space.language)
|
||||
.map((s) => s.id)}
|
||||
viewport={!withTopHeader ? 'mobile' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
interface SearchContainerProps {
|
||||
/** The current site space id. */
|
||||
siteSpaceId: string;
|
||||
|
||||
/** The title of the current space. */
|
||||
spaceTitle: string;
|
||||
isMultiVariants: boolean;
|
||||
|
||||
/** The ids of all spaces in the current section. */
|
||||
siteSpaceIds: string[];
|
||||
|
||||
/** Whether there are sections on the site. */
|
||||
withSections: boolean;
|
||||
|
||||
/** The current section, displayed in search scope toggle. */
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
|
||||
/** Whether the current section has variants. */
|
||||
withVariants: boolean;
|
||||
|
||||
/** Whether any section on the site has variants. */
|
||||
withSiteVariants: boolean;
|
||||
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
viewport?: 'desktop' | 'mobile';
|
||||
@@ -30,7 +48,18 @@ interface SearchContainerProps {
|
||||
* Client component to render the search input and results.
|
||||
*/
|
||||
export function SearchContainer(props: SearchContainerProps) {
|
||||
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
|
||||
const {
|
||||
siteSpaceId,
|
||||
spaceTitle,
|
||||
section,
|
||||
withVariants,
|
||||
withSiteVariants,
|
||||
withSections,
|
||||
style,
|
||||
className,
|
||||
viewport,
|
||||
siteSpaceIds,
|
||||
} = props;
|
||||
|
||||
const { assistants } = useAI();
|
||||
|
||||
@@ -108,7 +137,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
}
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||
open: true,
|
||||
}));
|
||||
@@ -148,7 +177,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
|
||||
query: value,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: true,
|
||||
}));
|
||||
};
|
||||
@@ -168,15 +197,22 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
state?.query || withAI ? (
|
||||
<React.Suspense fallback={null}>
|
||||
{isMultiVariants && !showAsk ? (
|
||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||
{(withVariants || withSections) && !showAsk ? (
|
||||
<SearchScopeToggle
|
||||
section={section}
|
||||
spaceTitle={spaceTitle}
|
||||
withVariants={withVariants}
|
||||
withSiteVariants={withSiteVariants}
|
||||
withSections={withSections}
|
||||
/>
|
||||
) : null}
|
||||
{state !== null && !showAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
global={state?.global ?? false}
|
||||
scope={state?.scope ?? 'default'}
|
||||
siteSpaceId={siteSpaceId}
|
||||
siteSpaceIds={siteSpaceIds}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
@@ -194,7 +230,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className:
|
||||
'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
'@container bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 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)) {
|
||||
|
||||
@@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||
import {
|
||||
type OrderedComputedResult,
|
||||
searchAllSiteContent,
|
||||
searchSiteSpaceContent,
|
||||
searchCurrentSiteSpaceContent,
|
||||
searchSpecificSiteSpaceContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import type { SearchScope } from './useSearch';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
moveUp(): void;
|
||||
@@ -50,12 +52,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
global: boolean;
|
||||
scope: SearchScope;
|
||||
siteSpaceId: string;
|
||||
siteSpaceIds: string[];
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, query, global, siteSpaceId } = props;
|
||||
const { children, query, scope, siteSpaceId, siteSpaceIds } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -133,9 +136,25 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
setResultsState((prev) => ({ results: prev.results, fetching: true }));
|
||||
let cancelled = false;
|
||||
const timeout = setTimeout(async () => {
|
||||
const results = await (global
|
||||
? searchAllSiteContent(query)
|
||||
: searchSiteSpaceContent(query));
|
||||
const results = await (() => {
|
||||
if (scope === 'all') {
|
||||
// Search all content on the site
|
||||
return searchAllSiteContent(query);
|
||||
}
|
||||
if (scope === 'default') {
|
||||
// Search the current section's variant + matched/default variant for other sections
|
||||
return searchCurrentSiteSpaceContent(query, siteSpaceId);
|
||||
}
|
||||
if (scope === 'extended') {
|
||||
// Search all variants of the current section
|
||||
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
|
||||
}
|
||||
if (scope === 'current') {
|
||||
// Search only the current section's current variant
|
||||
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
|
||||
}
|
||||
throw new Error(`Unhandled search scope: ${scope}`);
|
||||
})();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -158,7 +177,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [query, global, trackEvent, withAI, siteSpaceId]);
|
||||
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAI) {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Button } from '../primitives';
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
/**
|
||||
* Toolbar to toggle between search modes (global or scoped to a space).
|
||||
* Only visible when the space is in a collection.
|
||||
*/
|
||||
export function SearchScopeToggle(props: { spaceTitle: string }) {
|
||||
const { spaceTitle } = props;
|
||||
export function SearchScopeToggle(props: {
|
||||
spaceTitle: string;
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
withVariants: boolean;
|
||||
withSiteVariants: boolean;
|
||||
withSections: boolean;
|
||||
}) {
|
||||
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
|
||||
const [state, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
@@ -16,37 +25,82 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
|
||||
>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="shrink grow justify-center whitespace-normal"
|
||||
active={!state.global}
|
||||
label={tString(language, 'search_scope_space', spaceTitle)}
|
||||
onClick={() => {
|
||||
setSearchState({
|
||||
...state,
|
||||
global: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="shrink grow justify-center whitespace-normal"
|
||||
active={state.global}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
onClick={() => {
|
||||
setSearchState({
|
||||
...state,
|
||||
global: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{withSections ? (
|
||||
<SegmentedControl className="animate-scale-in">
|
||||
{/* `Default` scope = current section's current variant + best match in other sections */}
|
||||
<SegmentedControlItem
|
||||
active={
|
||||
withSiteVariants
|
||||
? state.scope === 'default'
|
||||
: ['default', 'all'].includes(state.scope)
|
||||
}
|
||||
label={
|
||||
withSiteVariants
|
||||
? tString(language, 'search_scope_default')
|
||||
: tString(language, 'search_scope_all')
|
||||
}
|
||||
className={withSiteVariants ? '@max-md:basis-full' : ''}
|
||||
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
|
||||
onClick={() => setSearchState({ ...state, scope: 'default' })}
|
||||
/>
|
||||
|
||||
{/* `Current` scope = current section's current variant (with further variant scope selection if necessary) */}
|
||||
<SegmentedControlItem
|
||||
active={state.scope === 'current' || state.scope === 'extended'}
|
||||
icon={section?.icon ?? 'crosshairs'}
|
||||
label={tString(language, 'search_scope_current', section?.title)}
|
||||
onClick={() => setSearchState({ ...state, scope: 'current' })}
|
||||
/>
|
||||
|
||||
{/* `All` scope = all content on the site. Only visible if site has variants, otherwise it's the same as default */}
|
||||
{withSiteVariants ? (
|
||||
<SegmentedControlItem
|
||||
active={state.scope === 'all'}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
icon="infinity"
|
||||
onClick={() => setSearchState({ ...state, scope: 'all' })}
|
||||
/>
|
||||
) : null}
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
{withVariants &&
|
||||
(!withSections || state.scope === 'current' || state.scope === 'extended') ? (
|
||||
<SegmentedControl className="animate-scale-in">
|
||||
{/* `Current` scope = current section's current variant. `Default` on sites without sections. */}
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={
|
||||
withSections
|
||||
? state.scope === 'current'
|
||||
: ['default', 'current'].includes(state.scope)
|
||||
}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_current', spaceTitle)}
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: withSections ? 'current' : 'default',
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* `Extended` scope = all variants of the current section. `All` on sites without sections. */}
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={
|
||||
withSections
|
||||
? state.scope === 'extended'
|
||||
: ['extended', 'all'].includes(state.scope)
|
||||
}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_extended')}
|
||||
onClick={() =>
|
||||
setSearchState({ ...state, scope: withSections ? 'extended' : 'all' })
|
||||
}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,18 +81,33 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
|
||||
/**
|
||||
* Server action to search content in a space.
|
||||
*/
|
||||
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
|
||||
export async function searchCurrentSiteSpaceContent(
|
||||
query: string,
|
||||
siteSpaceId: string
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
// which searches in the current space + all default spaces of sections
|
||||
scope: siteURLData.siteSection
|
||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||
scope: { mode: 'current', siteSpaceId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in a specific space.
|
||||
*/
|
||||
export async function searchSpecificSiteSpaceContent(
|
||||
query: string,
|
||||
siteSpaceIds: string[]
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
scope: { mode: 'specific', siteSpaceIds },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export type SearchScope =
|
||||
/** Search all content on the site */
|
||||
| 'all'
|
||||
/** Search the current section's variant + matched/default variant for other sections */
|
||||
| 'default'
|
||||
/** Search all variants of the current section */
|
||||
| 'extended'
|
||||
/** Search only the current section's current variant */
|
||||
| 'current';
|
||||
|
||||
export interface SearchState {
|
||||
// URL-backed state
|
||||
query: string | null;
|
||||
ask: string | null;
|
||||
global: boolean;
|
||||
scope: SearchScope;
|
||||
|
||||
// Local UI state
|
||||
open: boolean;
|
||||
@@ -18,7 +28,8 @@ export interface SearchState {
|
||||
const keyMap = {
|
||||
q: parseAsString,
|
||||
ask: parseAsString,
|
||||
global: parseAsBoolean,
|
||||
scope: parseAsStringLiteral(['all', 'default', 'extended', 'current']).withDefault('default'),
|
||||
global: parseAsBoolean, // Legacy support for global=true
|
||||
};
|
||||
|
||||
export type UpdateSearchState = (
|
||||
@@ -41,14 +52,21 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
history: 'replace',
|
||||
});
|
||||
|
||||
// Handle legacy ask=true format by converting it to the new format
|
||||
React.useEffect(() => {
|
||||
// Handle legacy ask=true format by converting it to the new format
|
||||
if (rawState?.ask === 'true' && rawState?.q) {
|
||||
// Convert legacy format: q=query&ask=true -> ask=query&q=null
|
||||
setRawState({
|
||||
q: null,
|
||||
ask: rawState.q,
|
||||
global: rawState.global,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy global=true
|
||||
if (rawState?.global === true) {
|
||||
setRawState({
|
||||
scope: 'all',
|
||||
global: null, // Remove the legacy parameter
|
||||
});
|
||||
}
|
||||
}, [rawState, setRawState]);
|
||||
@@ -65,7 +83,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
return {
|
||||
query: rawState.q,
|
||||
ask: rawState.ask,
|
||||
global: !!rawState.global,
|
||||
scope: rawState.scope,
|
||||
open,
|
||||
};
|
||||
}, [rawState, open]);
|
||||
@@ -85,14 +103,14 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
|
||||
if (update === null) {
|
||||
setIsOpen(false);
|
||||
return setRawState({ q: null, ask: null, global: null });
|
||||
return setRawState({ q: null, ask: null, scope: 'default' });
|
||||
}
|
||||
|
||||
setIsOpen(update.open);
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask,
|
||||
global: update.global ? true : null,
|
||||
scope: update.scope,
|
||||
});
|
||||
},
|
||||
[setRawState]
|
||||
@@ -126,7 +144,7 @@ export function useSearchLink(): (
|
||||
const searchParams = new URLSearchParams();
|
||||
params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
|
||||
params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
|
||||
params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||
params.scope ? searchParams.set('scope', params.scope) : searchParams.delete('scope');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
@@ -137,7 +155,7 @@ export function useSearchLink(): (
|
||||
...prev,
|
||||
query: params.query !== undefined ? params.query : null,
|
||||
ask: params.ask !== undefined ? params.ask : null,
|
||||
global: params.global !== undefined ? params.global : false,
|
||||
scope: params.scope !== undefined ? params.scope : 'default',
|
||||
open: params.open !== undefined ? params.open : false,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -180,9 +180,29 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<div className="flex gap-2">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) =>
|
||||
s.space.language ===
|
||||
siteSpace.space.language
|
||||
).length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={withSections}
|
||||
section={sections?.current}
|
||||
spaceTitle={siteSpace.title}
|
||||
siteSpaceId={siteSpace.id}
|
||||
siteSpaceIds={siteSpaces
|
||||
.filter(
|
||||
(s) =>
|
||||
s.space.language ===
|
||||
siteSpace.space.language
|
||||
)
|
||||
.map((s) => s.id)}
|
||||
className="max-lg:hidden"
|
||||
viewport="desktop"
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ export const variantClasses = {
|
||||
'bg-transparent',
|
||||
'text-tint',
|
||||
'border-0',
|
||||
'contrast-more:border',
|
||||
'shadow-none!',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:text-tint-strong',
|
||||
@@ -80,9 +81,9 @@ export const variantClasses = {
|
||||
],
|
||||
};
|
||||
|
||||
const activeClasses = {
|
||||
export const activeClasses = {
|
||||
primary: 'bg-primary-solid-hover',
|
||||
blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
|
||||
blank: 'bg-primary-active contrast-more:bg-primary-12 contrast-more:text-contrast-primary-12 disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
|
||||
secondary: 'bg-tint-active disabled:bg-tint-active',
|
||||
header: 'bg-header-link/3',
|
||||
};
|
||||
@@ -134,7 +135,7 @@ export const Button = React.forwardRef<
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
className={tcls('button-leading-icon size-[1em]')}
|
||||
className={tcls('button-leading-icon size-[1em] shrink-0')}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
@@ -154,6 +155,7 @@ export const Button = React.forwardRef<
|
||||
aria-label={label?.toString()}
|
||||
aria-pressed={active}
|
||||
target={target}
|
||||
data-active={active}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
@@ -166,6 +168,7 @@ export const Button = React.forwardRef<
|
||||
aria-label={label?.toString()}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
data-active={active}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
@@ -146,10 +146,20 @@ export function DropdownMenuItem(
|
||||
active?: boolean;
|
||||
className?: ClassValue;
|
||||
children: React.ReactNode;
|
||||
leadingIcon?: IconName | React.ReactNode;
|
||||
} & LinkInsightsProps &
|
||||
RadixDropdownMenu.DropdownMenuItemProps
|
||||
) {
|
||||
const { children, active = false, href, className, insights, target, ...rest } = props;
|
||||
const {
|
||||
children,
|
||||
active = false,
|
||||
href,
|
||||
className,
|
||||
insights,
|
||||
target,
|
||||
leadingIcon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const itemClassName = tcls(
|
||||
'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
|
||||
@@ -161,10 +171,22 @@ export function DropdownMenuItem(
|
||||
className
|
||||
);
|
||||
|
||||
const icon = leadingIcon ? (
|
||||
typeof leadingIcon === 'string' ? (
|
||||
<Icon
|
||||
icon={leadingIcon as IconName}
|
||||
className={tcls('size-4 shrink-0', active ? 'text-primary' : 'text-tint-subtle')}
|
||||
/>
|
||||
) : (
|
||||
leadingIcon
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<RadixDropdownMenu.Item {...rest} asChild>
|
||||
<Link href={href} insights={insights} className={itemClassName} target={target}>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
</RadixDropdownMenu.Item>
|
||||
@@ -173,6 +195,7 @@ export function DropdownMenuItem(
|
||||
|
||||
return (
|
||||
<RadixDropdownMenu.Item {...rest} className={tcls('px-3 py-1', itemClassName, className)}>
|
||||
{icon}
|
||||
{children}
|
||||
</RadixDropdownMenu.Item>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
export function SegmentedControl(props: { children: React.ReactNode; className?: ClassValue }) {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className={tcls(
|
||||
'mb-2 flex flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint p-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedControlItem(props: ButtonProps) {
|
||||
const { size = 'medium', className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="blank"
|
||||
size={size}
|
||||
className={tcls(
|
||||
'shrink grow justify-center whitespace-normal not-contrast-more:data-[active=true]:bg-tint-base',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export const de = {
|
||||
search_no_results_for: 'Keine Ergebnisse für "${1}".',
|
||||
search_no_results: 'Keine Ergebnisse',
|
||||
search_results_count: '${1} Ergebnisse',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alle Inhalte',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alle Inhalte',
|
||||
search_scope_default: 'Beste Match',
|
||||
search_scope_all: 'Alles',
|
||||
ask: 'Fragen',
|
||||
search_ask: 'Fragen "${1}"',
|
||||
search_ask_description: 'Finden Sie die Antwort mit ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const en = {
|
||||
search_no_results_for: 'No results for "${1}".',
|
||||
search_no_results: 'No results',
|
||||
search_results_count: '${1} results',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'All content',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'All content',
|
||||
search_scope_default: 'Best match',
|
||||
search_scope_all: 'Everything',
|
||||
ask: 'Ask',
|
||||
search_ask: 'Ask "${1}"',
|
||||
search_ask_description: 'Find the answer with ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const es: TranslationLanguage = {
|
||||
search_no_results_for: 'No hay resultados para "${1}".',
|
||||
search_no_results: 'No hay resultados',
|
||||
search_results_count: '${1} resultados',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Todo el contenido',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Todo el contenido',
|
||||
search_scope_default: 'Mejor match',
|
||||
search_scope_all: 'Todo',
|
||||
ask: 'Preguntar',
|
||||
search_ask: 'Preguntar "${1}"',
|
||||
search_ask_description: 'Encuentra la respuesta con ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const fr = {
|
||||
search_no_results_for: 'Aucun résultat pour « ${1} ».',
|
||||
search_no_results: 'Aucun résultat',
|
||||
search_results_count: '${1} résultats',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Tous les contenus',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Tous les contenus',
|
||||
search_scope_default: 'Meilleur match',
|
||||
search_scope_all: 'Tout',
|
||||
ask: 'Poser une question',
|
||||
search_ask: 'Demander « ${1} »',
|
||||
search_ask_description: 'Trouvez la réponse avec ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const ja: TranslationLanguage = {
|
||||
search_no_results_for: '"${1}" の結果はありません。',
|
||||
search_no_results: '結果がありません',
|
||||
search_results_count: '${1}件の結果',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: '全てのコンテンツ',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: '全てのコンテンツ',
|
||||
search_scope_default: '最適なマッチ',
|
||||
search_scope_all: 'すべて',
|
||||
ask: '質問する',
|
||||
search_ask: '"${1}" を質問する',
|
||||
search_ask_description: '${1}で答えを見つける',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const nl: TranslationLanguage = {
|
||||
search_no_results_for: 'Geen resultaten voor "${1}".',
|
||||
search_no_results: 'Geen resultaten',
|
||||
search_results_count: '${1} resultaten',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alle inhoud',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alle inhoud',
|
||||
search_scope_default: 'Beste match',
|
||||
search_scope_all: 'Alles',
|
||||
ask: 'Vragen',
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const no: TranslationLanguage = {
|
||||
search_no_results_for: 'Ingen resultater for "${1}".',
|
||||
search_no_results: 'Ingen resultater',
|
||||
search_results_count: '${1} resultater',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alt innhold',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alt innhold',
|
||||
search_scope_default: 'Beste treff',
|
||||
search_scope_all: 'Alt',
|
||||
ask: 'Spør',
|
||||
search_ask: 'Spør "${1}"',
|
||||
search_ask_description: 'Finn svaret med ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const pt_br = {
|
||||
search_no_results_for: 'Sem resultados para "${1}".',
|
||||
search_no_results: 'Sem resultados',
|
||||
search_results_count: '${1} resultados',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Todo o conteúdo',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Todo o conteúdo',
|
||||
search_scope_default: 'Melhor match',
|
||||
search_scope_all: 'Tudo',
|
||||
ask: 'Perguntar',
|
||||
search_ask: 'Perguntar "${1}"',
|
||||
search_ask_description: 'Encontre a resposta com ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const ru = {
|
||||
search_no_results_for: 'Нет результатов для "${1}".',
|
||||
search_no_results: 'Нет результатов',
|
||||
search_results_count: '${1} — число результатов',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Все материалы',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Все материалы',
|
||||
search_scope_default: 'Лучший матч',
|
||||
search_scope_all: 'Всё',
|
||||
ask: 'Спросить',
|
||||
search_ask: 'Спросить "${1}"',
|
||||
search_ask_description: 'Найти ответ с помощью ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const zh: TranslationLanguage = {
|
||||
search_no_results_for: '没有找到"${1}"的结果。',
|
||||
search_no_results: '没有找到结果',
|
||||
search_results_count: '${1} 个结果',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: '所有内容',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: '所有内容',
|
||||
search_scope_default: '最佳匹配',
|
||||
search_scope_all: '全部',
|
||||
ask: '询问',
|
||||
search_ask: '询问"${1}"',
|
||||
search_ask_description: '利用${1}找到答案',
|
||||
|
||||
Reference in New Issue
Block a user