refactor(search): extract search into workspace package

This commit is contained in:
Maofeng
2026-09-20 15:52:09 +08:00
parent e02c3dceb5
commit 1fc56b6982
34 changed files with 262 additions and 360 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@workspace/search",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-hotkeys": "^0.10.0",
"@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*",
"cmdk": "^1.1.1",
"react": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts",
"./globals.css": "./src/styles/globals.css",
"./locales": "./src/locales/catalogs.ts",
"./locales/*": "./src/locales/*.ts"
}
}
+56
View File
@@ -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
}
+222
View File
@@ -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
}
+4
View File
@@ -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>()
+35
View File
@@ -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 }]
}
+79
View File
@@ -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>
)
}
+16
View File
@@ -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)
}
}
+16
View File
@@ -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>
>
+27
View File
@@ -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
+27
View File
@@ -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
+64
View File
@@ -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>
+175
View File
@@ -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>
)
})
}
+140
View File
@@ -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]" },
])
})
})
+87
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
@source "../";
+14
View File
@@ -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} />
}
+61
View File
@@ -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
+101
View File
@@ -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.
}
}
+135
View File
@@ -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()]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/i18n": ["../i18n/src/index.ts"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}