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:
@@ -10,6 +10,7 @@
|
||||
"@workspace/blocks/media/locales/{locale}",
|
||||
"@workspace/blocks/navigation/locales/{locale}",
|
||||
"@workspace/blocks/notifications/locales/{locale}",
|
||||
"@workspace/blocks/search/locales/{locale}",
|
||||
"@workspace/lexical/locales/{locale}"
|
||||
],
|
||||
"include": ["src", "../../packages/blocks/src", "../../packages/ui/src"],
|
||||
|
||||
@@ -65,6 +65,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",
|
||||
|
||||
@@ -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 l’historique",
|
||||
"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 l’espace 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 l’espace 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()]
|
||||
}
|
||||
@@ -14,11 +14,15 @@ function createDialogHandle<Payload>(): DialogHandle<Payload> {
|
||||
return DialogPrimitive.createHandle<Payload>()
|
||||
}
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
function Dialog<Payload = unknown>({
|
||||
...props
|
||||
}: DialogPrimitive.Root.Props<Payload>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
function DialogTrigger<Payload = unknown>({
|
||||
...props
|
||||
}: DialogPrimitive.Trigger.Props<Payload>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user