feat(blocks): add context-driven global search

Add a command-style global search dialog with asynchronous cancellation, paginated grouped results, highlighting, loading and error states, recent query history, and a configurable Mod+K shortcut.

Make search sources and result actions host-defined through SearchProvider and SearchAdapter, so the block has no route, database, or HTTP dependency. Export the block, register i18n discovery, and cover adapter pagination and selection behavior.
This commit is contained in:
Maofeng
2026-07-31 16:04:30 +08:00
parent 324d8c0244
commit 38d2defeb1
28 changed files with 1461 additions and 3 deletions
+5 -1
View File
@@ -18,6 +18,7 @@
"@tanstack/react-router": "^1.170.18",
"@workspace/i18n": "workspace:*",
"@workspace/ui": "workspace:*",
"cmdk": "^1.1.1",
"lucide-react": "^1.27.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
@@ -52,6 +53,9 @@
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
"./notifications": "./src/blocks/notifications/index.ts",
"./notifications/locales": "./src/blocks/notifications/locales/catalogs.ts",
"./notifications/locales/*": "./src/blocks/notifications/locales/*.ts"
"./notifications/locales/*": "./src/blocks/notifications/locales/*.ts",
"./search": "./src/blocks/search/index.ts",
"./search/locales": "./src/blocks/search/locales/catalogs.ts",
"./search/locales/*": "./src/blocks/search/locales/*.ts"
}
}
@@ -0,0 +1,54 @@
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 blocks. */
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 blocks must be rendered inside a SearchProvider.")
}
return value
}
@@ -0,0 +1,218 @@
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 "lucide-react"
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 { Dialog } from "@base-ui/react/dialog"
/** A shared handle for the optional global-search trigger. */
export const searchDialogHandle = Dialog.createHandle<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 "lucide-react"
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,34 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { searchMessages } from "../messages"
import { searchCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("search locale catalogs", () => {
it("ship every search message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: searchCatalogLocales,
messageIds: Object.values(searchMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -0,0 +1,10 @@
import type { searchMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as searchCatalogLocales }
export type SearchCatalogLocale = BlockCatalogLocale
export type SearchMessageCatalog = BlockMessageCatalog<typeof searchMessages>
@@ -0,0 +1,29 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.search.actions.clear": "Suche löschen",
"blocks.search.actions.clearHistory": "Verlauf löschen",
"blocks.search.actions.close": "Suche schließen",
"blocks.search.actions.loadMore": "Mehr laden",
"blocks.search.actions.loadingMore": "Wird geladen…",
"blocks.search.actions.retry": "Erneut versuchen",
"blocks.search.actions.selectResult": "{title} öffnen",
"blocks.search.command.description": "Im gesamten Arbeitsbereich suchen",
"blocks.search.description": "Inhalte im gesamten Arbeitsbereich finden",
"blocks.search.empty.description": "Keine Ergebnisse für „{query}“",
"blocks.search.empty.title": "Keine Ergebnisse gefunden",
"blocks.search.error.description":
"Prüfe deine Verbindung und versuche es erneut.",
"blocks.search.error.title": "Suche nach „{query}“ fehlgeschlagen",
"blocks.search.history.remove": "„{query}“ aus den letzten Suchen entfernen",
"blocks.search.history.title": "Letzte Suchen",
"blocks.search.initial.description":
"Gib ein Stichwort ein, um den Arbeitsbereich zu durchsuchen.",
"blocks.search.initial.title": "Suche starten",
"blocks.search.loading": "Suche läuft…",
"blocks.search.placeholder": "Arbeitsbereich durchsuchen…",
"blocks.search.results.count": "{count} Ergebnisse",
"blocks.search.title": "Suche",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,28 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.search.actions.clear": "Clear query",
"blocks.search.actions.clearHistory": "Clear history",
"blocks.search.actions.close": "Close search",
"blocks.search.actions.loadMore": "Load more",
"blocks.search.actions.loadingMore": "Loading…",
"blocks.search.actions.retry": "Try again",
"blocks.search.actions.selectResult": "Open {title}",
"blocks.search.command.description": "Search across your workspace",
"blocks.search.description": "Find content across your workspace",
"blocks.search.empty.description": "No results for “{query}”",
"blocks.search.empty.title": "No results found",
"blocks.search.error.description": "Check your connection and try again.",
"blocks.search.error.title": "Could not search for “{query}”",
"blocks.search.history.remove": "Remove “{query}” from recent searches",
"blocks.search.history.title": "Recent searches",
"blocks.search.initial.description":
"Enter a keyword to search your workspace.",
"blocks.search.initial.title": "Start searching",
"blocks.search.loading": "Searching…",
"blocks.search.placeholder": "Search your workspace…",
"blocks.search.results.count": "{count} results",
"blocks.search.title": "Search",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,30 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.search.actions.clear": "Borrar búsqueda",
"blocks.search.actions.clearHistory": "Borrar historial",
"blocks.search.actions.close": "Cerrar búsqueda",
"blocks.search.actions.loadMore": "Cargar más",
"blocks.search.actions.loadingMore": "Cargando…",
"blocks.search.actions.retry": "Intentar de nuevo",
"blocks.search.actions.selectResult": "Abrir {title}",
"blocks.search.command.description": "Buscar en todo tu espacio de trabajo",
"blocks.search.description":
"Encuentra contenido en todo tu espacio de trabajo",
"blocks.search.empty.description": "No hay resultados para «{query}»",
"blocks.search.empty.title": "No se encontraron resultados",
"blocks.search.error.description":
"Comprueba tu conexión e inténtalo de nuevo.",
"blocks.search.error.title": "No se pudo buscar «{query}»",
"blocks.search.history.remove": "Quitar «{query}» de las búsquedas recientes",
"blocks.search.history.title": "Búsquedas recientes",
"blocks.search.initial.description":
"Introduce una palabra clave para buscar en tu espacio de trabajo.",
"blocks.search.initial.title": "Empezar a buscar",
"blocks.search.loading": "Buscando…",
"blocks.search.placeholder": "Buscar en tu espacio de trabajo…",
"blocks.search.results.count": "{count} resultados",
"blocks.search.title": "Buscar",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,30 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.search.actions.clear": "Effacer la recherche",
"blocks.search.actions.clearHistory": "Effacer lhistorique",
"blocks.search.actions.close": "Fermer la recherche",
"blocks.search.actions.loadMore": "Charger plus",
"blocks.search.actions.loadingMore": "Chargement…",
"blocks.search.actions.retry": "Réessayer",
"blocks.search.actions.selectResult": "Ouvrir {title}",
"blocks.search.command.description":
"Rechercher dans tout lespace de travail",
"blocks.search.description":
"Trouver du contenu dans votre espace de travail",
"blocks.search.empty.description": "Aucun résultat pour « {query} »",
"blocks.search.empty.title": "Aucun résultat trouvé",
"blocks.search.error.description": "Vérifiez votre connexion et réessayez.",
"blocks.search.error.title": "Impossible de rechercher « {query} »",
"blocks.search.history.remove": "Retirer « {query} » des recherches récentes",
"blocks.search.history.title": "Recherches récentes",
"blocks.search.initial.description":
"Saisissez un mot-clé pour rechercher dans votre espace de travail.",
"blocks.search.initial.title": "Commencer la recherche",
"blocks.search.loading": "Recherche…",
"blocks.search.placeholder": "Rechercher dans lespace de travail…",
"blocks.search.results.count": "{count} résultats",
"blocks.search.title": "Rechercher",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,28 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.search.actions.clear": "検索をクリア",
"blocks.search.actions.clearHistory": "履歴を削除",
"blocks.search.actions.close": "検索を閉じる",
"blocks.search.actions.loadMore": "さらに読み込む",
"blocks.search.actions.loadingMore": "読み込み中…",
"blocks.search.actions.retry": "再試行",
"blocks.search.actions.selectResult": "{title} を開く",
"blocks.search.command.description": "ワークスペース全体を検索",
"blocks.search.description": "ワークスペース全体からコンテンツを探す",
"blocks.search.empty.description": "「{query}」の結果はありません",
"blocks.search.empty.title": "結果が見つかりません",
"blocks.search.error.description": "接続を確認して、もう一度お試しください。",
"blocks.search.error.title": "「{query}」を検索できませんでした",
"blocks.search.history.remove": "最近の検索から「{query}」を削除",
"blocks.search.history.title": "最近の検索",
"blocks.search.initial.description":
"キーワードを入力してワークスペースを検索します。",
"blocks.search.initial.title": "検索を始める",
"blocks.search.loading": "検索中…",
"blocks.search.placeholder": "ワークスペースを検索…",
"blocks.search.results.count": "{count} 件の結果",
"blocks.search.title": "検索",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,28 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.search.actions.clear": "검색어 지우기",
"blocks.search.actions.clearHistory": "기록 지우기",
"blocks.search.actions.close": "검색 닫기",
"blocks.search.actions.loadMore": "더 불러오기",
"blocks.search.actions.loadingMore": "불러오는 중…",
"blocks.search.actions.retry": "다시 시도",
"blocks.search.actions.selectResult": "{title} 열기",
"blocks.search.command.description": "작업 공간 전체 검색",
"blocks.search.description": "작업 공간 전체에서 콘텐츠 찾기",
"blocks.search.empty.description": "“{query}”에 대한 결과가 없습니다",
"blocks.search.empty.title": "결과를 찾을 수 없습니다",
"blocks.search.error.description": "연결을 확인한 후 다시 시도하세요.",
"blocks.search.error.title": "“{query}”을(를) 검색할 수 없습니다",
"blocks.search.history.remove": "최근 검색에서 “{query}” 제거",
"blocks.search.history.title": "최근 검색",
"blocks.search.initial.description":
"키워드를 입력하여 작업 공간을 검색하세요.",
"blocks.search.initial.title": "검색 시작",
"blocks.search.loading": "검색 중…",
"blocks.search.placeholder": "작업 공간 검색…",
"blocks.search.results.count": "결과 {count}개",
"blocks.search.title": "검색",
} 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 = {
"blocks.search.actions.clear": "清除查询",
"blocks.search.actions.clearHistory": "清空历史",
"blocks.search.actions.close": "关闭搜索",
"blocks.search.actions.loadMore": "加载更多",
"blocks.search.actions.loadingMore": "正在加载…",
"blocks.search.actions.retry": "重试",
"blocks.search.actions.selectResult": "打开 {title}",
"blocks.search.command.description": "搜索整个工作区",
"blocks.search.description": "在工作区中查找内容",
"blocks.search.empty.description": "没有找到与“{query}”相关的内容",
"blocks.search.empty.title": "未找到结果",
"blocks.search.error.description": "请检查网络连接后重试。",
"blocks.search.error.title": "无法搜索“{query}”",
"blocks.search.history.remove": "从搜索历史中删除“{query}”",
"blocks.search.history.title": "搜索历史",
"blocks.search.initial.description": "输入关键词以搜索工作区。",
"blocks.search.initial.title": "开始搜索",
"blocks.search.loading": "正在搜索…",
"blocks.search.placeholder": "搜索工作区…",
"blocks.search.results.count": "{count} 条结果",
"blocks.search.title": "搜索",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,27 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.search.actions.clear": "清除搜尋",
"blocks.search.actions.clearHistory": "清除記錄",
"blocks.search.actions.close": "關閉搜尋",
"blocks.search.actions.loadMore": "載入更多",
"blocks.search.actions.loadingMore": "載入中…",
"blocks.search.actions.retry": "再試一次",
"blocks.search.actions.selectResult": "開啟 {title}",
"blocks.search.command.description": "搜尋整個工作區",
"blocks.search.description": "在工作區中尋找內容",
"blocks.search.empty.description": "沒有「{query}」的結果",
"blocks.search.empty.title": "找不到結果",
"blocks.search.error.description": "請檢查網絡連線後再試一次。",
"blocks.search.error.title": "無法搜尋「{query}」",
"blocks.search.history.remove": "從最近搜尋中移除「{query}」",
"blocks.search.history.title": "最近搜尋",
"blocks.search.initial.description": "輸入關鍵字以搜尋工作區。",
"blocks.search.initial.title": "開始搜尋",
"blocks.search.loading": "搜尋中…",
"blocks.search.placeholder": "搜尋工作區…",
"blocks.search.results.count": "{count} 項結果",
"blocks.search.title": "搜尋",
} as const satisfies SearchMessageCatalog
@@ -0,0 +1,64 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const searchMessages = {
clear: { id: "blocks.search.actions.clear", message: "Clear query" },
clearHistory: {
id: "blocks.search.actions.clearHistory",
message: "Clear history",
},
close: { id: "blocks.search.actions.close", message: "Close search" },
loadMore: { id: "blocks.search.actions.loadMore", message: "Load more" },
loadingMore: {
id: "blocks.search.actions.loadingMore",
message: "Loading…",
},
retry: { id: "blocks.search.actions.retry", message: "Try again" },
selectResult: {
id: "blocks.search.actions.selectResult",
message: "Open {title}",
},
commandDescription: {
id: "blocks.search.command.description",
message: "Search across your workspace",
},
description: {
id: "blocks.search.description",
message: "Find content across your workspace",
},
emptyDescription: {
id: "blocks.search.empty.description",
message: "No results for “{query}”",
},
emptyTitle: { id: "blocks.search.empty.title", message: "No results found" },
errorDescription: {
id: "blocks.search.error.description",
message: "Check your connection and try again.",
},
errorTitle: {
id: "blocks.search.error.title",
message: "Could not search for “{query}”",
},
history: { id: "blocks.search.history.title", message: "Recent searches" },
initialDescription: {
id: "blocks.search.initial.description",
message: "Enter a keyword to search your workspace.",
},
initialTitle: {
id: "blocks.search.initial.title",
message: "Start searching",
},
loading: { id: "blocks.search.loading", message: "Searching…" },
placeholder: {
id: "blocks.search.placeholder",
message: "Search your workspace…",
},
removeHistory: {
id: "blocks.search.history.remove",
message: "Remove “{query}” from recent searches",
},
resultCount: {
id: "blocks.search.results.count",
message: "{count} results",
},
title: { id: "blocks.search.title", message: "Search" },
} as const satisfies Record<string, MessageDescriptor>
@@ -0,0 +1,173 @@
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 "lucide-react"
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, index) => (
<span
key={`${line}:${index}`}
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 }) {
return splitHighlightSegments(text, query).map((segment, index) =>
segment.highlighted ? (
<mark
key={index}
className="rounded-sm bg-primary/15 px-0.5 text-primary"
>
{segment.text}
</mark>
) : (
<React.Fragment key={index}>{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"
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" catalogs={{ en: englishMessages }}>
<SearchProvider
adapter={adapter}
historyStorageKey={false}
onSelect={onSelect}
>
<SearchDialog hotkey={false} />
</SearchProvider>
</I18nProvider>
)
}
describe("search blocks", () => {
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,83 @@
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 "lucide-react"
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,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 blocks. 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 { SearchCursor, 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()]
}