diff --git a/apps/web/i18n.config.json b/apps/web/i18n.config.json index 157edd4..021978d 100644 --- a/apps/web/i18n.config.json +++ b/apps/web/i18n.config.json @@ -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"], diff --git a/bun.lock b/bun.lock index bf5edf2..27baf12 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/blocks/package.json b/packages/blocks/package.json index ee55999..e92126a 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -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" } } diff --git a/packages/blocks/src/blocks/search/context.tsx b/packages/blocks/src/blocks/search/context.tsx new file mode 100644 index 0000000..f95b75a --- /dev/null +++ b/packages/blocks/src/blocks/search/context.tsx @@ -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(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( + () => ({ + adapter, + historyLimit: Math.max(1, Math.floor(historyLimit)), + historyStorageKey: + historyStorageKey === false ? undefined : historyStorageKey, + onSelect, + }), + [adapter, historyLimit, historyStorageKey, onSelect] + ) + + return ( + {children} + ) +} + +export function useSearchContext(): SearchContextValue { + const value = React.useContext(SearchContext) + if (!value) { + throw new Error("Search blocks must be rendered inside a SearchProvider.") + } + return value +} diff --git a/packages/blocks/src/blocks/search/dialog.tsx b/packages/blocks/src/blocks/search/dialog.tsx new file mode 100644 index 0000000..603991a --- /dev/null +++ b/packages/blocks/src/blocks/search/dialog.tsx @@ -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[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 ( + + {hotkey && } + + {title ?? t(searchMessages.title)} + {t(searchMessages.description)} + + + +
+ + {query.trim() && ( + <> + +
+ + )} + +
+ + {search.error && !hasResults ? ( +
+ +
+ +
+
+ ) : search.isLoading ? ( + + ) : hasResults ? ( + + ) : deferredQuery ? ( + + ) : history.items.length > 0 ? ( + + ) : ( + + )} + + +
+ ) +} + +function SearchHotkey({ + hotkey, + onToggle, +}: { + hotkey: Parameters[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 +} diff --git a/packages/blocks/src/blocks/search/handle.ts b/packages/blocks/src/blocks/search/handle.ts new file mode 100644 index 0000000..922f966 --- /dev/null +++ b/packages/blocks/src/blocks/search/handle.ts @@ -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() diff --git a/packages/blocks/src/blocks/search/highlight.ts b/packages/blocks/src/blocks/search/highlight.ts new file mode 100644 index 0000000..e7866ba --- /dev/null +++ b/packages/blocks/src/blocks/search/highlight.ts @@ -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 }] +} diff --git a/packages/blocks/src/blocks/search/history.tsx b/packages/blocks/src/blocks/search/history.tsx new file mode 100644 index 0000000..26ed5e5 --- /dev/null +++ b/packages/blocks/src/blocks/search/history.tsx @@ -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 ( +
+ +
+ + {t(searchMessages.history)} + + +
+ + {items.map((item) => ( + 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" + > + + {item} + + + ))} + +
+
+ ) +} diff --git a/packages/blocks/src/blocks/search/index.ts b/packages/blocks/src/blocks/search/index.ts new file mode 100644 index 0000000..a6a9b9f --- /dev/null +++ b/packages/blocks/src/blocks/search/index.ts @@ -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" diff --git a/packages/blocks/src/blocks/search/locales/catalogs.test.ts b/packages/blocks/src/blocks/search/locales/catalogs.test.ts new file mode 100644 index 0000000..c071ff6 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/catalogs.test.ts @@ -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 + ), + }) + }) +}) diff --git a/packages/blocks/src/blocks/search/locales/catalogs.ts b/packages/blocks/src/blocks/search/locales/catalogs.ts new file mode 100644 index 0000000..9880353 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/catalogs.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/de.ts b/packages/blocks/src/blocks/search/locales/de.ts new file mode 100644 index 0000000..b9a90a6 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/de.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/en.ts b/packages/blocks/src/blocks/search/locales/en.ts new file mode 100644 index 0000000..a6ed423 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/en.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/es.ts b/packages/blocks/src/blocks/search/locales/es.ts new file mode 100644 index 0000000..c6bc02f --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/es.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/fr.ts b/packages/blocks/src/blocks/search/locales/fr.ts new file mode 100644 index 0000000..7b51207 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/fr.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/ja.ts b/packages/blocks/src/blocks/search/locales/ja.ts new file mode 100644 index 0000000..1654766 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/ja.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/ko.ts b/packages/blocks/src/blocks/search/locales/ko.ts new file mode 100644 index 0000000..3a19fd9 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/ko.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/zh-Hans.ts b/packages/blocks/src/blocks/search/locales/zh-Hans.ts new file mode 100644 index 0000000..b710c02 --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/zh-Hans.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/locales/zh-Hant.ts b/packages/blocks/src/blocks/search/locales/zh-Hant.ts new file mode 100644 index 0000000..089535d --- /dev/null +++ b/packages/blocks/src/blocks/search/locales/zh-Hant.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/messages.ts b/packages/blocks/src/blocks/search/messages.ts new file mode 100644 index 0000000..73e7cbf --- /dev/null +++ b/packages/blocks/src/blocks/search/messages.ts @@ -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 diff --git a/packages/blocks/src/blocks/search/results.tsx b/packages/blocks/src/blocks/search/results.tsx new file mode 100644 index 0000000..3d25b41 --- /dev/null +++ b/packages/blocks/src/blocks/search/results.tsx @@ -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 ( + + + {groups.map((group) => { + if (group.items.length === 0) return null + + return ( + + {group.icon} + {group.label} + + {t(searchMessages.resultCount, { + count: group.total ?? group.items.length, + })} + + + } + > + {group.items.map((item) => ( + + ))} + + ) + })} + {hasMore && ( +
+ +
+ )} +
+
+ ) +} + +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 ( + 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 })} + > + + + + + + {item.description && ( + + + + )} + {item.meta?.map((line, index) => ( + + + + ))} + + + + ) +} + +function ResultMedia({ item }: { item: SearchResultItem }) { + if (item.image) { + return ( + {item.image.alt + ) + } + + return ( + + {item.icon ?? } + + ) +} + +function HighlightedText({ text, query }: { text: string; query: string }) { + return splitHighlightSegments(text, query).map((segment, index) => + segment.highlighted ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + ) +} diff --git a/packages/blocks/src/blocks/search/search.test.tsx b/packages/blocks/src/blocks/search/search.test.tsx new file mode 100644 index 0000000..458d88d --- /dev/null +++ b/packages/blocks/src/blocks/search/search.test.tsx @@ -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( + + + + + + ) +} + +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]" }, + ]) + }) +}) diff --git a/packages/blocks/src/blocks/search/states.tsx b/packages/blocks/src/blocks/search/states.tsx new file mode 100644 index 0000000..b536f89 --- /dev/null +++ b/packages/blocks/src/blocks/search/states.tsx @@ -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 ( + } + title={t(searchMessages.initialTitle)} + description={t(searchMessages.initialDescription)} + /> + ) +} + +export function SearchLoading() { + const t = useTranslate() + return ( + } + description={t(searchMessages.loading)} + /> + ) +} + +export function SearchEmpty({ query }: { query: string }) { + const t = useTranslate() + return ( + } + title={t(searchMessages.emptyTitle)} + description={t(searchMessages.emptyDescription, { query })} + /> + ) +} + +export function SearchError({ query }: { query: string }) { + const t = useTranslate() + return ( + } + 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 ( + + + + {icon} + + {title && {title}} + {description} + + + ) +} diff --git a/packages/blocks/src/blocks/search/trigger.tsx b/packages/blocks/src/blocks/search/trigger.tsx new file mode 100644 index 0000000..7e7040c --- /dev/null +++ b/packages/blocks/src/blocks/search/trigger.tsx @@ -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, + "handle" +> + +/** Opens the nearest mounted SearchDialog through its shared dialog handle. */ +export function SearchTrigger(props: SearchTriggerProps) { + return +} diff --git a/packages/blocks/src/blocks/search/types.ts b/packages/blocks/src/blocks/search/types.ts new file mode 100644 index 0000000..615d38b --- /dev/null +++ b/packages/blocks/src/blocks/search/types.ts @@ -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 +} + +export interface SearchSelectionEvent { + item: SearchResultItem + query: string +} + +export type SearchSelectionHandler = (event: SearchSelectionEvent) => void diff --git a/packages/blocks/src/blocks/search/use-search-history.ts b/packages/blocks/src/blocks/search/use-search-history.ts new file mode 100644 index 0000000..777c1fe --- /dev/null +++ b/packages/blocks/src/blocks/search/use-search-history.ts @@ -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(() => + 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. + } +} diff --git a/packages/blocks/src/blocks/search/use-search.ts b/packages/blocks/src/blocks/search/use-search.ts new file mode 100644 index 0000000..2018ba7 --- /dev/null +++ b/packages/blocks/src/blocks/search/use-search.ts @@ -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(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() + + 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()] +} diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index ba105ba..504ce6a 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -14,11 +14,15 @@ function createDialogHandle(): DialogHandle { return DialogPrimitive.createHandle() } -function Dialog({ ...props }: DialogPrimitive.Root.Props) { +function Dialog({ + ...props +}: DialogPrimitive.Root.Props) { return } -function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { +function DialogTrigger({ + ...props +}: DialogPrimitive.Trigger.Props) { return }