refactor(search): extract search into workspace package
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { SearchAdapter, SearchSelectionHandler } from "./types"
|
||||
|
||||
export interface SearchContextValue {
|
||||
adapter: SearchAdapter
|
||||
historyLimit: number
|
||||
historyStorageKey?: string
|
||||
onSelect?: SearchSelectionHandler
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextValue | null>(null)
|
||||
|
||||
export interface SearchProviderProps {
|
||||
adapter: SearchAdapter
|
||||
children: React.ReactNode
|
||||
/** Maximum number of recent queries persisted by the default history hook. */
|
||||
historyLimit?: number
|
||||
/** Set to false to keep search history only in memory. */
|
||||
historyStorageKey?: string | false
|
||||
onSelect?: SearchSelectionHandler
|
||||
}
|
||||
|
||||
/** Supplies the host application's search implementation to the search package. */
|
||||
export function SearchProvider({
|
||||
adapter,
|
||||
children,
|
||||
historyLimit = 32,
|
||||
historyStorageKey = "workspace-search-history",
|
||||
onSelect,
|
||||
}: SearchProviderProps) {
|
||||
const value = React.useMemo<SearchContextValue>(
|
||||
() => ({
|
||||
adapter,
|
||||
historyLimit: Math.max(1, Math.floor(historyLimit)),
|
||||
historyStorageKey:
|
||||
historyStorageKey === false ? undefined : historyStorageKey,
|
||||
onSelect,
|
||||
}),
|
||||
[adapter, historyLimit, historyStorageKey, onSelect]
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={value}>{children}</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSearchContext(): SearchContextValue {
|
||||
const value = React.useContext(SearchContext)
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
"Search components must be rendered inside a SearchProvider."
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import {
|
||||
LoaderCircleIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from "@workspace/ui/components/icon"
|
||||
|
||||
import { useSearchContext } from "./context"
|
||||
import { searchDialogHandle } from "./handle"
|
||||
import { SearchHistory } from "./history"
|
||||
import { searchMessages } from "./messages"
|
||||
import { SearchResults } from "./results"
|
||||
import {
|
||||
SearchEmpty,
|
||||
SearchError,
|
||||
SearchInitial,
|
||||
SearchLoading,
|
||||
} from "./states"
|
||||
import type { SearchResultItem, SearchSelectionHandler } from "./types"
|
||||
import { useSearch } from "./use-search"
|
||||
import { useSearchHistory } from "./use-search-history"
|
||||
|
||||
export interface SearchDialogProps {
|
||||
className?: string
|
||||
/** Set to false when the host application registers its own shortcut. */
|
||||
hotkey?: Parameters<typeof useHotkey>[0] | false
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSelect?: SearchSelectionHandler
|
||||
placeholder?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A command-style global search dialog. Data fetching and result selection are
|
||||
* delegated to the nearest SearchProvider.
|
||||
*/
|
||||
export function SearchDialog({
|
||||
className,
|
||||
hotkey = "Mod+K",
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
title,
|
||||
}: SearchDialogProps) {
|
||||
const {
|
||||
historyLimit,
|
||||
historyStorageKey,
|
||||
onSelect: contextOnSelect,
|
||||
} = useSearchContext()
|
||||
const t = useTranslate()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [query, setQuery] = React.useState("")
|
||||
const deferredQuery = React.useDeferredValue(query.trim())
|
||||
const history = useSearchHistory({
|
||||
limit: historyLimit,
|
||||
storageKey: historyStorageKey,
|
||||
})
|
||||
const search = useSearch({ active: open, query: deferredQuery })
|
||||
const hasResults = search.groups.some((group) => group.items.length > 0)
|
||||
|
||||
const setDialogOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
const toggle = React.useCallback(() => {
|
||||
if (open) {
|
||||
searchDialogHandle.close()
|
||||
} else {
|
||||
searchDialogHandle.open(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectResult = React.useCallback(
|
||||
(item: SearchResultItem) => {
|
||||
history.add(query)
|
||||
setQuery("")
|
||||
searchDialogHandle.close()
|
||||
const selectionHandler = onSelect ?? contextOnSelect
|
||||
selectionHandler?.({ item, query: query.trim() })
|
||||
},
|
||||
[contextOnSelect, history, onSelect, query]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog handle={searchDialogHandle} onOpenChange={setDialogOpen}>
|
||||
{hotkey && <SearchHotkey hotkey={hotkey} onToggle={toggle} />}
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title ?? t(searchMessages.title)}</DialogTitle>
|
||||
<DialogDescription>{t(searchMessages.description)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-20 flex max-h-[calc(100dvh-2rem)] min-h-0 translate-y-0 flex-col gap-0 overflow-hidden bg-popover p-0 sm:max-w-[min(94vw,48rem)]",
|
||||
className
|
||||
)}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Command
|
||||
className="group/search flex min-h-0 flex-col overflow-hidden"
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2 border-b bg-popover px-3 py-1 text-popover-foreground">
|
||||
<label className="flex h-full min-w-0 flex-1 items-center gap-2">
|
||||
<span className="inline-flex size-8 shrink-0 items-center justify-center">
|
||||
{search.isLoading || search.isLoadingMore ? (
|
||||
<LoaderCircleIcon className="animate-spin text-primary" />
|
||||
) : (
|
||||
<SearchIcon className="opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="min-w-0 flex-1 border-none bg-transparent text-lg outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={placeholder ?? t(searchMessages.placeholder)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||
history.add(query)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{query.trim() && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 appearance-none bg-transparent p-2 text-foreground underline underline-offset-4 transition-colors outline-none hover:text-destructive"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
{t(searchMessages.clear)}
|
||||
</button>
|
||||
<div className="h-4 w-px shrink-0 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-foreground/75 hover:text-destructive"
|
||||
onClick={() => searchDialogHandle.close()}
|
||||
aria-label={t(searchMessages.close)}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{search.error && !hasResults ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SearchError query={deferredQuery} />
|
||||
<div className="pb-6 text-center">
|
||||
<Button variant="outline" onClick={search.retry}>
|
||||
{t(searchMessages.retry)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : search.isLoading ? (
|
||||
<SearchLoading />
|
||||
) : hasResults ? (
|
||||
<SearchResults
|
||||
groups={search.groups}
|
||||
query={deferredQuery}
|
||||
onSelect={selectResult}
|
||||
onLoadMore={search.loadMore}
|
||||
hasMore={search.hasMore}
|
||||
isLoadingMore={search.isLoadingMore}
|
||||
/>
|
||||
) : deferredQuery ? (
|
||||
<SearchEmpty query={deferredQuery} />
|
||||
) : history.items.length > 0 ? (
|
||||
<SearchHistory
|
||||
items={history.items}
|
||||
onSelect={setQuery}
|
||||
onRemove={history.remove}
|
||||
onClear={history.clear}
|
||||
/>
|
||||
) : (
|
||||
<SearchInitial />
|
||||
)}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchHotkey({
|
||||
hotkey,
|
||||
onToggle,
|
||||
}: {
|
||||
hotkey: Parameters<typeof useHotkey>[0]
|
||||
onToggle: VoidFunction
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
|
||||
useHotkey(hotkey, onToggle, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: t(searchMessages.title),
|
||||
description: t(searchMessages.commandDescription),
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createDialogHandle } from "@workspace/ui/components/dialog"
|
||||
|
||||
/** A shared handle for the optional global-search trigger. */
|
||||
export const searchDialogHandle = createDialogHandle<void>()
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface HighlightSegment {
|
||||
highlighted: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
export function splitHighlightSegments(
|
||||
text: string,
|
||||
query: string
|
||||
): readonly HighlightSegment[] {
|
||||
const normalizedQuery = query.trim()
|
||||
if (!normalizedQuery) return [{ highlighted: false, text }]
|
||||
|
||||
const matcher = new RegExp(escapeRegExp(normalizedQuery), "giu")
|
||||
const segments: HighlightSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(matcher)) {
|
||||
const index = match.index ?? 0
|
||||
if (index > cursor) {
|
||||
segments.push({ highlighted: false, text: text.slice(cursor, index) })
|
||||
}
|
||||
segments.push({ highlighted: true, text: match[0] })
|
||||
cursor = index + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ highlighted: false, text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ highlighted: false, text }]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Command } from "cmdk"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { HistoryIcon, Trash2Icon, XIcon } from "@workspace/ui/components/icon"
|
||||
|
||||
import { searchMessages } from "./messages"
|
||||
|
||||
export interface SearchHistoryProps {
|
||||
className?: string
|
||||
items: readonly string[]
|
||||
onSelect: (keyword: string) => void
|
||||
onRemove: (keyword: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function SearchHistory({
|
||||
className,
|
||||
items,
|
||||
onClear,
|
||||
onRemove,
|
||||
onSelect,
|
||||
}: SearchHistoryProps) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-[min(65dvh,36rem)] min-h-0 flex-col", className)}
|
||||
>
|
||||
<ScrollArea className="min-h-0 flex-1 p-4">
|
||||
<div className="sticky top-0 z-1 flex items-center justify-between bg-sidebar/90 ps-2 pb-2 backdrop-blur-xs">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{t(searchMessages.history)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="hover:bg-destructive/10 hover:text-destructive"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
>
|
||||
<Trash2Icon />
|
||||
{t(searchMessages.clearHistory)}
|
||||
</Button>
|
||||
</div>
|
||||
<Command.List
|
||||
data-slot="command-list"
|
||||
className="gap-1 px-1 pb-1 **:[[cmdk-list-sizer]]:space-y-1"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Command.Item
|
||||
key={item}
|
||||
value={item}
|
||||
onSelect={() => onSelect(item)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-sidebar-border/55 bg-popover p-2 shadow-xs transition-colors hover:bg-secondary [&>svg]:last:hidden"
|
||||
>
|
||||
<HistoryIcon className="size-4 text-muted-foreground opacity-65" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{item}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-xs"
|
||||
className="translate-x-1 opacity-100 group-hover/command-item:opacity-70 hover:opacity-100"
|
||||
aria-label={t(searchMessages.removeHistory, { query: item })}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onRemove(item)
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { SearchProvider, useSearchContext } from "./context"
|
||||
export type { SearchContextValue, SearchProviderProps } from "./context"
|
||||
export { SearchDialog, type SearchDialogProps } from "./dialog"
|
||||
export { searchDialogHandle } from "./handle"
|
||||
export { splitHighlightSegments, type HighlightSegment } from "./highlight"
|
||||
export { SearchTrigger, type SearchTriggerProps } from "./trigger"
|
||||
export type {
|
||||
SearchAdapter,
|
||||
SearchCursor,
|
||||
SearchPage,
|
||||
SearchRequest,
|
||||
SearchResultGroup,
|
||||
SearchResultItem,
|
||||
SearchSelectionEvent,
|
||||
SearchSelectionHandler,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { searchMessages } from "../messages"
|
||||
import { searchCatalogLocales } from "./catalogs"
|
||||
import { messages as enUS } from "./en-US"
|
||||
import { messages as zhHans } from "./zh-Hans"
|
||||
|
||||
describe("search locale catalogs", () => {
|
||||
it("ship every search message in every built-in locale", () => {
|
||||
expectCompleteCatalogs({
|
||||
catalogs: {
|
||||
"en-US": enUS,
|
||||
"zh-Hans": zhHans,
|
||||
},
|
||||
locales: searchCatalogLocales,
|
||||
messageIds: Object.values(searchMessages).map(
|
||||
(descriptor) => descriptor.id
|
||||
),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function expectCompleteCatalogs({
|
||||
catalogs,
|
||||
locales,
|
||||
messageIds,
|
||||
}: {
|
||||
catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>
|
||||
locales: readonly string[]
|
||||
messageIds: readonly string[]
|
||||
}) {
|
||||
expect(Object.keys(catalogs)).toEqual([...locales])
|
||||
|
||||
const expectedIds = new Set(messageIds)
|
||||
for (const messages of Object.values(catalogs)) {
|
||||
expect(new Set(Object.keys(messages))).toEqual(expectedIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { searchMessages } from "../messages"
|
||||
|
||||
export const searchCatalogLocales = ["en-US", "zh-Hans"] as const
|
||||
|
||||
interface MessageDescriptorMap {
|
||||
readonly [key: string]: {
|
||||
readonly id: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
|
||||
|
||||
export type SearchCatalogLocale = (typeof searchCatalogLocales)[number]
|
||||
export type SearchMessageCatalog = Readonly<
|
||||
Record<MessageId<typeof searchMessages>, string>
|
||||
>
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "en-US"
|
||||
export const languageTag = "en-US"
|
||||
export const messages = {
|
||||
"search.actions.clear": "Clear query",
|
||||
"search.actions.clearHistory": "Clear history",
|
||||
"search.actions.close": "Close search",
|
||||
"search.actions.loadMore": "Load more",
|
||||
"search.actions.loadingMore": "Loading…",
|
||||
"search.actions.retry": "Try again",
|
||||
"search.actions.selectResult": "Open {title}",
|
||||
"search.command.description": "Search across your workspace",
|
||||
"search.description": "Find content across your workspace",
|
||||
"search.empty.description": "No results for “{query}”",
|
||||
"search.empty.title": "No results found",
|
||||
"search.error.description": "Check your connection and try again.",
|
||||
"search.error.title": "Could not search for “{query}”",
|
||||
"search.history.remove": "Remove “{query}” from recent searches",
|
||||
"search.history.title": "Recent searches",
|
||||
"search.initial.description": "Enter a keyword to search your workspace.",
|
||||
"search.initial.title": "Start searching",
|
||||
"search.loading": "Searching…",
|
||||
"search.placeholder": "Search your workspace…",
|
||||
"search.results.count": "{count} results",
|
||||
"search.title": "Search",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hans"
|
||||
export const languageTag = "zh-CN"
|
||||
export const messages = {
|
||||
"search.actions.clear": "清除查询",
|
||||
"search.actions.clearHistory": "清空历史",
|
||||
"search.actions.close": "关闭搜索",
|
||||
"search.actions.loadMore": "加载更多",
|
||||
"search.actions.loadingMore": "正在加载…",
|
||||
"search.actions.retry": "重试",
|
||||
"search.actions.selectResult": "打开 {title}",
|
||||
"search.command.description": "搜索整个工作区",
|
||||
"search.description": "在工作区中查找内容",
|
||||
"search.empty.description": "没有找到与“{query}”相关的内容",
|
||||
"search.empty.title": "未找到结果",
|
||||
"search.error.description": "请检查网络连接后重试。",
|
||||
"search.error.title": "无法搜索“{query}”",
|
||||
"search.history.remove": "从搜索历史中删除“{query}”",
|
||||
"search.history.title": "搜索历史",
|
||||
"search.initial.description": "输入关键词以搜索工作区。",
|
||||
"search.initial.title": "开始搜索",
|
||||
"search.loading": "正在搜索…",
|
||||
"search.placeholder": "搜索工作区…",
|
||||
"search.results.count": "{count} 条结果",
|
||||
"search.title": "搜索",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
export const searchMessages = {
|
||||
clear: { id: "search.actions.clear", message: "Clear query" },
|
||||
clearHistory: {
|
||||
id: "search.actions.clearHistory",
|
||||
message: "Clear history",
|
||||
},
|
||||
close: { id: "search.actions.close", message: "Close search" },
|
||||
loadMore: { id: "search.actions.loadMore", message: "Load more" },
|
||||
loadingMore: {
|
||||
id: "search.actions.loadingMore",
|
||||
message: "Loading…",
|
||||
},
|
||||
retry: { id: "search.actions.retry", message: "Try again" },
|
||||
selectResult: {
|
||||
id: "search.actions.selectResult",
|
||||
message: "Open {title}",
|
||||
},
|
||||
commandDescription: {
|
||||
id: "search.command.description",
|
||||
message: "Search across your workspace",
|
||||
},
|
||||
description: {
|
||||
id: "search.description",
|
||||
message: "Find content across your workspace",
|
||||
},
|
||||
emptyDescription: {
|
||||
id: "search.empty.description",
|
||||
message: "No results for “{query}”",
|
||||
},
|
||||
emptyTitle: { id: "search.empty.title", message: "No results found" },
|
||||
errorDescription: {
|
||||
id: "search.error.description",
|
||||
message: "Check your connection and try again.",
|
||||
},
|
||||
errorTitle: {
|
||||
id: "search.error.title",
|
||||
message: "Could not search for “{query}”",
|
||||
},
|
||||
history: { id: "search.history.title", message: "Recent searches" },
|
||||
initialDescription: {
|
||||
id: "search.initial.description",
|
||||
message: "Enter a keyword to search your workspace.",
|
||||
},
|
||||
initialTitle: {
|
||||
id: "search.initial.title",
|
||||
message: "Start searching",
|
||||
},
|
||||
loading: { id: "search.loading", message: "Searching…" },
|
||||
placeholder: {
|
||||
id: "search.placeholder",
|
||||
message: "Search your workspace…",
|
||||
},
|
||||
removeHistory: {
|
||||
id: "search.history.remove",
|
||||
message: "Remove “{query}” from recent searches",
|
||||
},
|
||||
resultCount: {
|
||||
id: "search.results.count",
|
||||
message: "{count} results",
|
||||
},
|
||||
title: { id: "search.title", message: "Search" },
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
||||
import {
|
||||
CornerDownLeftIcon,
|
||||
FileTextIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "@workspace/ui/components/icon"
|
||||
|
||||
import { splitHighlightSegments } from "./highlight"
|
||||
import { searchMessages } from "./messages"
|
||||
import type { SearchResultGroup, SearchResultItem } from "./types"
|
||||
|
||||
export interface SearchResultsProps {
|
||||
groups: readonly SearchResultGroup[]
|
||||
hasMore: boolean
|
||||
isLoadingMore: boolean
|
||||
onLoadMore: VoidFunction
|
||||
onSelect: (item: SearchResultItem) => void
|
||||
query: string
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
groups,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
onSelect,
|
||||
query,
|
||||
}: SearchResultsProps) {
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[min(65dvh,36rem)] p-4">
|
||||
<Command.List className="**:[[cmdk-list-sizer]]:space-y-6">
|
||||
{groups.map((group) => {
|
||||
if (group.items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Command.Group
|
||||
key={group.id}
|
||||
className="space-y-2 **:[[cmdk-group-heading]]:sticky **:[[cmdk-group-heading]]:top-0 **:[[cmdk-group-heading]]:z-2 **:[[cmdk-group-heading]]:flex **:[[cmdk-group-heading]]:items-center **:[[cmdk-group-heading]]:gap-2 **:[[cmdk-group-heading]]:bg-popover/90 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:backdrop-blur-xs **:[[cmdk-group-items]]:space-y-1"
|
||||
heading={
|
||||
<>
|
||||
{group.icon}
|
||||
<span className="font-semibold">{group.label}</span>
|
||||
<span className="ms-auto text-xs text-muted-foreground">
|
||||
{t(searchMessages.resultCount, {
|
||||
count: group.total ?? group.items.length,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<SearchResultRow
|
||||
key={`${group.id}:${item.id}`}
|
||||
item={item}
|
||||
onSelect={onSelect}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
)
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isLoadingMore}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
{isLoadingMore && <LoaderCircleIcon className="animate-spin" />}
|
||||
{isLoadingMore
|
||||
? t(searchMessages.loadingMore)
|
||||
: t(searchMessages.loadMore)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Command.List>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultRow({
|
||||
item,
|
||||
onSelect,
|
||||
query,
|
||||
}: {
|
||||
item: SearchResultItem
|
||||
onSelect: (item: SearchResultItem) => void
|
||||
query: string
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
const value = [
|
||||
item.id,
|
||||
item.title,
|
||||
item.description,
|
||||
...(item.meta ?? []),
|
||||
...(item.keywords ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<Command.Item
|
||||
value={value}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => onSelect(item)}
|
||||
className="group/search-result flex items-center gap-3 rounded-xl border border-border/55 bg-background p-3 shadow-xs transition-colors hover:bg-muted"
|
||||
aria-label={t(searchMessages.selectResult, { title: item.title })}
|
||||
>
|
||||
<ResultMedia item={item} />
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate">
|
||||
<HighlightedText text={item.title} query={query} />
|
||||
</span>
|
||||
{item.description && (
|
||||
<span className="block truncate text-sm text-muted-foreground">
|
||||
<HighlightedText text={item.description} query={query} />
|
||||
</span>
|
||||
)}
|
||||
{item.meta?.map((line) => (
|
||||
<span
|
||||
key={line}
|
||||
className="block truncate text-xs text-muted-foreground"
|
||||
>
|
||||
<HighlightedText text={line} query={query} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<CornerDownLeftIcon className="shrink-0 text-primary opacity-0 transition-opacity group-hover/search-result:opacity-100 group-data-selected/search-result:opacity-100" />
|
||||
</Command.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultMedia({ item }: { item: SearchResultItem }) {
|
||||
if (item.image) {
|
||||
return (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt ?? ""}
|
||||
loading="lazy"
|
||||
className="size-10 shrink-0 self-start rounded-xl border bg-muted object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="grid size-10 shrink-0 place-items-center self-start rounded-xl bg-muted text-muted-foreground">
|
||||
{item.icon ?? <FileTextIcon className="size-5" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
let offset = 0
|
||||
|
||||
return splitHighlightSegments(text, query).map((segment) => {
|
||||
const key = `${offset}:${segment.highlighted ? "highlight" : "text"}`
|
||||
offset += segment.text.length
|
||||
|
||||
return segment.highlighted ? (
|
||||
<mark key={key} className="rounded-sm bg-primary/15 px-0.5 text-primary">
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<React.Fragment key={key}>{segment.text}</React.Fragment>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { SearchProvider } from "./context"
|
||||
import { SearchDialog } from "./dialog"
|
||||
import { searchDialogHandle } from "./handle"
|
||||
import { splitHighlightSegments } from "./highlight"
|
||||
import { messages as englishMessages } from "./locales/en-US"
|
||||
import type { SearchAdapter } from "./types"
|
||||
|
||||
class ResizeObserverStub {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
||||
Object.defineProperty(Element.prototype, "getAnimations", {
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
})
|
||||
|
||||
function renderSearch({
|
||||
adapter,
|
||||
onSelect,
|
||||
}: {
|
||||
adapter: SearchAdapter
|
||||
onSelect?: (event: { item: { id: string }; query: string }) => void
|
||||
}) {
|
||||
return render(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
<SearchProvider
|
||||
adapter={adapter}
|
||||
historyStorageKey={false}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<SearchDialog hotkey={false} />
|
||||
</SearchProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("search package", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it("delegates searching, pagination, and selection to the provider", async () => {
|
||||
const adapter: SearchAdapter = {
|
||||
search: vi.fn(async ({ cursor, query }) => {
|
||||
if (cursor) {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
id: "people",
|
||||
label: "People",
|
||||
items: [{ id: "2", title: "Grace Hopper" }],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
id: "people",
|
||||
label: "People",
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
title: "Ada Lovelace",
|
||||
description: `Matched ${query}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
nextCursor: "next-page",
|
||||
}
|
||||
}),
|
||||
}
|
||||
const onSelect = vi.fn()
|
||||
renderSearch({ adapter, onSelect })
|
||||
|
||||
searchDialogHandle.open(null)
|
||||
const input = await screen.findByPlaceholderText("Search your workspace…")
|
||||
fireEvent.change(input, { target: { value: "ada" } })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adapter.search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: "ada" })
|
||||
)
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Ada Lovelace")
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Grace Hopper")
|
||||
)
|
||||
expect(adapter.search).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ cursor: "next-page", query: "ada" })
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "Open Ada Lovelace" }))
|
||||
await waitFor(() =>
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({ id: "1" }),
|
||||
query: "ada",
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("splits case-insensitive literal query matches for highlighting", () => {
|
||||
expect(splitHighlightSegments("Ada ADA Lovelace", "ada")).toEqual([
|
||||
{ highlighted: true, text: "Ada" },
|
||||
{ highlighted: false, text: " " },
|
||||
{ highlighted: true, text: "ADA" },
|
||||
{ highlighted: false, text: " Lovelace" },
|
||||
])
|
||||
expect(splitHighlightSegments("price [usd]", "[usd]")).toEqual([
|
||||
{ highlighted: false, text: "price " },
|
||||
{ highlighted: true, text: "[usd]" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@workspace/ui/components/empty"
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
LoaderCircleIcon,
|
||||
SearchIcon,
|
||||
} from "@workspace/ui/components/icon"
|
||||
|
||||
import { searchMessages } from "./messages"
|
||||
|
||||
export function SearchInitial() {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon />}
|
||||
title={t(searchMessages.initialTitle)}
|
||||
description={t(searchMessages.initialDescription)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchLoading() {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<LoaderCircleIcon className="animate-spin" />}
|
||||
description={t(searchMessages.loading)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchEmpty({ query }: { query: string }) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon />}
|
||||
title={t(searchMessages.emptyTitle)}
|
||||
description={t(searchMessages.emptyDescription, { query })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchError({ query }: { query: string }) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<CircleAlertIcon />}
|
||||
title={t(searchMessages.errorTitle, { query })}
|
||||
description={t(searchMessages.errorDescription)}
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
description,
|
||||
destructive = false,
|
||||
icon,
|
||||
title,
|
||||
}: {
|
||||
description: React.ReactNode
|
||||
destructive?: boolean
|
||||
icon: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Empty className="min-h-72 border-0 py-16">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia
|
||||
variant="icon"
|
||||
className={destructive ? "text-destructive" : undefined}
|
||||
>
|
||||
{icon}
|
||||
</EmptyMedia>
|
||||
{title && <EmptyTitle>{title}</EmptyTitle>}
|
||||
<EmptyDescription>{description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@source "../";
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from "react"
|
||||
import { DialogTrigger } from "@workspace/ui/components/dialog"
|
||||
|
||||
import { searchDialogHandle } from "./handle"
|
||||
|
||||
export type SearchTriggerProps = Omit<
|
||||
React.ComponentProps<typeof DialogTrigger>,
|
||||
"handle"
|
||||
>
|
||||
|
||||
/** Opens the nearest mounted SearchDialog through its shared dialog handle. */
|
||||
export function SearchTrigger(props: SearchTriggerProps) {
|
||||
return <DialogTrigger {...props} handle={searchDialogHandle} />
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type * as React from "react"
|
||||
|
||||
export type SearchCursor = number | string
|
||||
|
||||
/** A resource returned by a host application's global search implementation. */
|
||||
export interface SearchResultItem {
|
||||
/** Stable only within its group; used to merge paginated results. */
|
||||
id: string
|
||||
/** Primary text shown in the result row. */
|
||||
title: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
/** Optional small visual. An image takes precedence when both are supplied. */
|
||||
icon?: React.ReactNode
|
||||
image?: {
|
||||
alt?: string
|
||||
src: string
|
||||
}
|
||||
/** Extra searchable text, not rendered by the default result row. */
|
||||
keywords?: readonly string[]
|
||||
/** Secondary lines rendered below the title. */
|
||||
meta?: readonly string[]
|
||||
/** Opaque application data consumed by the selection handler. */
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
/** A logical result source, such as products, projects, users, or documentation. */
|
||||
export interface SearchResultGroup {
|
||||
icon?: React.ReactNode
|
||||
id: string
|
||||
items: readonly SearchResultItem[]
|
||||
label: string
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface SearchPage {
|
||||
groups: readonly SearchResultGroup[]
|
||||
/** Omit or return null when there are no more results to load. */
|
||||
nextCursor?: SearchCursor | null
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
cursor?: SearchCursor
|
||||
query: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* The only data dependency of the search package. Implement this in the host
|
||||
* application with its own database, HTTP client, command registry, or index.
|
||||
*/
|
||||
export interface SearchAdapter {
|
||||
search(request: SearchRequest): Promise<SearchPage>
|
||||
}
|
||||
|
||||
export interface SearchSelectionEvent {
|
||||
item: SearchResultItem
|
||||
query: string
|
||||
}
|
||||
|
||||
export type SearchSelectionHandler = (event: SearchSelectionEvent) => void
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from "react"
|
||||
|
||||
interface UseSearchHistoryOptions {
|
||||
limit: number
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export function useSearchHistory({
|
||||
limit,
|
||||
storageKey,
|
||||
}: UseSearchHistoryOptions) {
|
||||
const [items, setItems] = React.useState<readonly string[]>(() =>
|
||||
readSearchHistory(storageKey, limit)
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const syncFromStorage = () => setItems(readSearchHistory(storageKey, limit))
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === storageKey) syncFromStorage()
|
||||
}
|
||||
|
||||
syncFromStorage()
|
||||
if (!storageKey || typeof window === "undefined") return
|
||||
|
||||
window.addEventListener("storage", handleStorage)
|
||||
return () => window.removeEventListener("storage", handleStorage)
|
||||
}, [limit, storageKey])
|
||||
|
||||
const update = React.useCallback(
|
||||
(updater: (current: readonly string[]) => readonly string[]) => {
|
||||
setItems((current) => {
|
||||
const next = updater(current).slice(0, limit)
|
||||
writeSearchHistory(storageKey, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[limit, storageKey]
|
||||
)
|
||||
|
||||
const add = React.useCallback(
|
||||
(query: string) => {
|
||||
const normalizedQuery = query.trim()
|
||||
if (!normalizedQuery) return
|
||||
|
||||
update((current) => [
|
||||
normalizedQuery,
|
||||
...current.filter((item) => item !== normalizedQuery),
|
||||
])
|
||||
},
|
||||
[update]
|
||||
)
|
||||
|
||||
const remove = React.useCallback(
|
||||
(query: string) =>
|
||||
update((current) => current.filter((item) => item !== query)),
|
||||
[update]
|
||||
)
|
||||
const clear = React.useCallback(() => update(() => []), [update])
|
||||
|
||||
return { add, clear, items, remove }
|
||||
}
|
||||
|
||||
function readSearchHistory(storageKey: string | undefined, limit: number) {
|
||||
if (!storageKey || typeof window === "undefined") return []
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey)
|
||||
if (!stored) return []
|
||||
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
parsed
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, limit)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSearchHistory(
|
||||
storageKey: string | undefined,
|
||||
items: readonly string[]
|
||||
) {
|
||||
if (!storageKey || typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
if (items.length === 0) {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
} else {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
}
|
||||
} catch {
|
||||
// Storage can be disabled or unavailable in private browsing contexts.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { SearchPage, SearchResultGroup } from "./types"
|
||||
import { useSearchContext } from "./context"
|
||||
|
||||
interface UseSearchOptions {
|
||||
active: boolean
|
||||
query: string
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
error: unknown
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
pages: readonly SearchPage[]
|
||||
}
|
||||
|
||||
const INITIAL_STATE: SearchState = {
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
pages: [],
|
||||
}
|
||||
|
||||
export function useSearch({ active, query }: UseSearchOptions) {
|
||||
const { adapter } = useSearchContext()
|
||||
const normalizedQuery = query.trim()
|
||||
const [state, setState] = React.useState<SearchState>(INITIAL_STATE)
|
||||
const [retryKey, setRetryKey] = React.useState(0)
|
||||
const requestId = React.useRef(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !normalizedQuery) {
|
||||
setState(INITIAL_STATE)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const currentRequest = ++requestId.current
|
||||
setState({ error: null, isLoading: true, isLoadingMore: false, pages: [] })
|
||||
|
||||
void adapter
|
||||
.search({ query: normalizedQuery, signal: controller.signal })
|
||||
.then((page) => {
|
||||
if (currentRequest !== requestId.current || controller.signal.aborted)
|
||||
return
|
||||
setState({
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
pages: [page],
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted || currentRequest !== requestId.current)
|
||||
return
|
||||
setState({ error, isLoading: false, isLoadingMore: false, pages: [] })
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [active, adapter, normalizedQuery, retryKey])
|
||||
|
||||
const groups = React.useMemo(() => mergeGroups(state.pages), [state.pages])
|
||||
const cursor = state.pages.at(-1)?.nextCursor ?? null
|
||||
|
||||
const loadMore = React.useCallback(() => {
|
||||
if (!active || !normalizedQuery || cursor === null || state.isLoadingMore)
|
||||
return
|
||||
|
||||
const controller = new AbortController()
|
||||
const currentRequest = ++requestId.current
|
||||
setState((current) => ({ ...current, error: null, isLoadingMore: true }))
|
||||
|
||||
void adapter
|
||||
.search({
|
||||
cursor,
|
||||
query: normalizedQuery,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((page) => {
|
||||
if (currentRequest !== requestId.current || controller.signal.aborted)
|
||||
return
|
||||
setState((current) => ({
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
pages: [...current.pages, page],
|
||||
}))
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (currentRequest !== requestId.current || controller.signal.aborted)
|
||||
return
|
||||
setState((current) => ({ ...current, error, isLoadingMore: false }))
|
||||
})
|
||||
}, [active, adapter, cursor, normalizedQuery, state.isLoadingMore])
|
||||
|
||||
const retry = React.useCallback(() => setRetryKey((value) => value + 1), [])
|
||||
|
||||
return {
|
||||
error: state.error,
|
||||
groups,
|
||||
hasMore: cursor !== null,
|
||||
isLoading: state.isLoading,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
retry,
|
||||
loadMore,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeGroups(
|
||||
pages: readonly SearchPage[]
|
||||
): readonly SearchResultGroup[] {
|
||||
const groups = new Map<string, SearchResultGroup>()
|
||||
|
||||
for (const page of pages) {
|
||||
for (const group of page.groups) {
|
||||
const existing = groups.get(group.id)
|
||||
if (!existing) {
|
||||
groups.set(group.id, { ...group, items: [...group.items] })
|
||||
continue
|
||||
}
|
||||
|
||||
const items = new Map(existing.items.map((item) => [item.id, item]))
|
||||
for (const item of group.items) items.set(item.id, item)
|
||||
groups.set(group.id, {
|
||||
...existing,
|
||||
...group,
|
||||
items: [...items.values()],
|
||||
total: group.total ?? existing.total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
Reference in New Issue
Block a user