refactor(search): extract search into workspace package
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
@import "@workspace/ui/globals.css";
|
@import "@workspace/ui/globals.css";
|
||||||
@import "@workspace/blocks/globals.css";
|
@import "@workspace/blocks/globals.css";
|
||||||
@import "@workspace/lexical/globals.css";
|
@import "@workspace/lexical/globals.css";
|
||||||
|
@import "@workspace/search/globals.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ui-content-top-gap: --spacing(2);
|
--ui-content-top-gap: --spacing(2);
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { Dialog } from "@base-ui/react/dialog"
|
|
||||||
|
|
||||||
/** A shared handle for the optional global-search trigger. */
|
|
||||||
export const searchDialogHandle = Dialog.createHandle<void>()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@workspace/search",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-hotkeys": "^0.10.0",
|
||||||
|
"@workspace/i18n": "workspace:*",
|
||||||
|
"@workspace/ui": "workspace:*",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"react": "^19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"typescript": "~6",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./globals.css": "./src/styles/globals.css",
|
||||||
|
"./locales": "./src/locales/catalogs.ts",
|
||||||
|
"./locales/*": "./src/locales/*.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export interface SearchProviderProps {
|
|||||||
onSelect?: SearchSelectionHandler
|
onSelect?: SearchSelectionHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Supplies the host application's search implementation to the search blocks. */
|
/** Supplies the host application's search implementation to the search package. */
|
||||||
export function SearchProvider({
|
export function SearchProvider({
|
||||||
adapter,
|
adapter,
|
||||||
children,
|
children,
|
||||||
@@ -48,7 +48,9 @@ export function SearchProvider({
|
|||||||
export function useSearchContext(): SearchContextValue {
|
export function useSearchContext(): SearchContextValue {
|
||||||
const value = React.useContext(SearchContext)
|
const value = React.useContext(SearchContext)
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error("Search blocks must be rendered inside a SearchProvider.")
|
throw new Error(
|
||||||
|
"Search components must be rendered inside a SearchProvider."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@workspace/ui/components/dialog"
|
} from "@workspace/ui/components/dialog"
|
||||||
import { cn } from "@workspace/ui/lib/utils"
|
import { cn } from "@workspace/ui/lib/utils"
|
||||||
import { LoaderCircleIcon, SearchIcon, XIcon } from "lucide-react"
|
import {
|
||||||
|
LoaderCircleIcon,
|
||||||
|
SearchIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "@workspace/ui/components/icon"
|
||||||
|
|
||||||
import { useSearchContext } from "./context"
|
import { useSearchContext } from "./context"
|
||||||
import { searchDialogHandle } from "./handle"
|
import { searchDialogHandle } from "./handle"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createDialogHandle } from "@workspace/ui/components/dialog"
|
||||||
|
|
||||||
|
/** A shared handle for the optional global-search trigger. */
|
||||||
|
export const searchDialogHandle = createDialogHandle<void>()
|
||||||
@@ -3,7 +3,7 @@ import { useTranslate } from "@workspace/i18n"
|
|||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
||||||
import { cn } from "@workspace/ui/lib/utils"
|
import { cn } from "@workspace/ui/lib/utils"
|
||||||
import { HistoryIcon, Trash2Icon, XIcon } from "lucide-react"
|
import { HistoryIcon, Trash2Icon, XIcon } from "@workspace/ui/components/icon"
|
||||||
|
|
||||||
import { searchMessages } from "./messages"
|
import { searchMessages } from "./messages"
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { searchMessages } from "../messages"
|
||||||
|
import { searchCatalogLocales } from "./catalogs"
|
||||||
|
import { messages as enUS } from "./en-US"
|
||||||
|
import { messages as zhHans } from "./zh-Hans"
|
||||||
|
|
||||||
|
describe("search locale catalogs", () => {
|
||||||
|
it("ship every search message in every built-in locale", () => {
|
||||||
|
expectCompleteCatalogs({
|
||||||
|
catalogs: {
|
||||||
|
"en-US": enUS,
|
||||||
|
"zh-Hans": zhHans,
|
||||||
|
},
|
||||||
|
locales: searchCatalogLocales,
|
||||||
|
messageIds: Object.values(searchMessages).map(
|
||||||
|
(descriptor) => descriptor.id
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function expectCompleteCatalogs({
|
||||||
|
catalogs,
|
||||||
|
locales,
|
||||||
|
messageIds,
|
||||||
|
}: {
|
||||||
|
catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>
|
||||||
|
locales: readonly string[]
|
||||||
|
messageIds: readonly string[]
|
||||||
|
}) {
|
||||||
|
expect(Object.keys(catalogs)).toEqual([...locales])
|
||||||
|
|
||||||
|
const expectedIds = new Set(messageIds)
|
||||||
|
for (const messages of Object.values(catalogs)) {
|
||||||
|
expect(new Set(Object.keys(messages))).toEqual(expectedIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { searchMessages } from "../messages"
|
||||||
|
|
||||||
|
export const searchCatalogLocales = ["en-US", "zh-Hans"] as const
|
||||||
|
|
||||||
|
interface MessageDescriptorMap {
|
||||||
|
readonly [key: string]: {
|
||||||
|
readonly id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
|
||||||
|
|
||||||
|
export type SearchCatalogLocale = (typeof searchCatalogLocales)[number]
|
||||||
|
export type SearchMessageCatalog = Readonly<
|
||||||
|
Record<MessageId<typeof searchMessages>, string>
|
||||||
|
>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { SearchMessageCatalog } from "./catalogs"
|
||||||
|
|
||||||
|
export const locale = "en-US"
|
||||||
|
export const languageTag = "en-US"
|
||||||
|
export const messages = {
|
||||||
|
"search.actions.clear": "Clear query",
|
||||||
|
"search.actions.clearHistory": "Clear history",
|
||||||
|
"search.actions.close": "Close search",
|
||||||
|
"search.actions.loadMore": "Load more",
|
||||||
|
"search.actions.loadingMore": "Loading…",
|
||||||
|
"search.actions.retry": "Try again",
|
||||||
|
"search.actions.selectResult": "Open {title}",
|
||||||
|
"search.command.description": "Search across your workspace",
|
||||||
|
"search.description": "Find content across your workspace",
|
||||||
|
"search.empty.description": "No results for “{query}”",
|
||||||
|
"search.empty.title": "No results found",
|
||||||
|
"search.error.description": "Check your connection and try again.",
|
||||||
|
"search.error.title": "Could not search for “{query}”",
|
||||||
|
"search.history.remove": "Remove “{query}” from recent searches",
|
||||||
|
"search.history.title": "Recent searches",
|
||||||
|
"search.initial.description": "Enter a keyword to search your workspace.",
|
||||||
|
"search.initial.title": "Start searching",
|
||||||
|
"search.loading": "Searching…",
|
||||||
|
"search.placeholder": "Search your workspace…",
|
||||||
|
"search.results.count": "{count} results",
|
||||||
|
"search.title": "Search",
|
||||||
|
} as const satisfies SearchMessageCatalog
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { SearchMessageCatalog } from "./catalogs"
|
||||||
|
|
||||||
|
export const locale = "zh-Hans"
|
||||||
|
export const languageTag = "zh-CN"
|
||||||
|
export const messages = {
|
||||||
|
"search.actions.clear": "清除查询",
|
||||||
|
"search.actions.clearHistory": "清空历史",
|
||||||
|
"search.actions.close": "关闭搜索",
|
||||||
|
"search.actions.loadMore": "加载更多",
|
||||||
|
"search.actions.loadingMore": "正在加载…",
|
||||||
|
"search.actions.retry": "重试",
|
||||||
|
"search.actions.selectResult": "打开 {title}",
|
||||||
|
"search.command.description": "搜索整个工作区",
|
||||||
|
"search.description": "在工作区中查找内容",
|
||||||
|
"search.empty.description": "没有找到与“{query}”相关的内容",
|
||||||
|
"search.empty.title": "未找到结果",
|
||||||
|
"search.error.description": "请检查网络连接后重试。",
|
||||||
|
"search.error.title": "无法搜索“{query}”",
|
||||||
|
"search.history.remove": "从搜索历史中删除“{query}”",
|
||||||
|
"search.history.title": "搜索历史",
|
||||||
|
"search.initial.description": "输入关键词以搜索工作区。",
|
||||||
|
"search.initial.title": "开始搜索",
|
||||||
|
"search.loading": "正在搜索…",
|
||||||
|
"search.placeholder": "搜索工作区…",
|
||||||
|
"search.results.count": "{count} 条结果",
|
||||||
|
"search.title": "搜索",
|
||||||
|
} as const satisfies SearchMessageCatalog
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { MessageDescriptor } from "@workspace/i18n"
|
||||||
|
|
||||||
|
export const searchMessages = {
|
||||||
|
clear: { id: "search.actions.clear", message: "Clear query" },
|
||||||
|
clearHistory: {
|
||||||
|
id: "search.actions.clearHistory",
|
||||||
|
message: "Clear history",
|
||||||
|
},
|
||||||
|
close: { id: "search.actions.close", message: "Close search" },
|
||||||
|
loadMore: { id: "search.actions.loadMore", message: "Load more" },
|
||||||
|
loadingMore: {
|
||||||
|
id: "search.actions.loadingMore",
|
||||||
|
message: "Loading…",
|
||||||
|
},
|
||||||
|
retry: { id: "search.actions.retry", message: "Try again" },
|
||||||
|
selectResult: {
|
||||||
|
id: "search.actions.selectResult",
|
||||||
|
message: "Open {title}",
|
||||||
|
},
|
||||||
|
commandDescription: {
|
||||||
|
id: "search.command.description",
|
||||||
|
message: "Search across your workspace",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
id: "search.description",
|
||||||
|
message: "Find content across your workspace",
|
||||||
|
},
|
||||||
|
emptyDescription: {
|
||||||
|
id: "search.empty.description",
|
||||||
|
message: "No results for “{query}”",
|
||||||
|
},
|
||||||
|
emptyTitle: { id: "search.empty.title", message: "No results found" },
|
||||||
|
errorDescription: {
|
||||||
|
id: "search.error.description",
|
||||||
|
message: "Check your connection and try again.",
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
id: "search.error.title",
|
||||||
|
message: "Could not search for “{query}”",
|
||||||
|
},
|
||||||
|
history: { id: "search.history.title", message: "Recent searches" },
|
||||||
|
initialDescription: {
|
||||||
|
id: "search.initial.description",
|
||||||
|
message: "Enter a keyword to search your workspace.",
|
||||||
|
},
|
||||||
|
initialTitle: {
|
||||||
|
id: "search.initial.title",
|
||||||
|
message: "Start searching",
|
||||||
|
},
|
||||||
|
loading: { id: "search.loading", message: "Searching…" },
|
||||||
|
placeholder: {
|
||||||
|
id: "search.placeholder",
|
||||||
|
message: "Search your workspace…",
|
||||||
|
},
|
||||||
|
removeHistory: {
|
||||||
|
id: "search.history.remove",
|
||||||
|
message: "Remove “{query}” from recent searches",
|
||||||
|
},
|
||||||
|
resultCount: {
|
||||||
|
id: "search.results.count",
|
||||||
|
message: "{count} results",
|
||||||
|
},
|
||||||
|
title: { id: "search.title", message: "Search" },
|
||||||
|
} as const satisfies Record<string, MessageDescriptor>
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
CornerDownLeftIcon,
|
CornerDownLeftIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
} from "lucide-react"
|
} from "@workspace/ui/components/icon"
|
||||||
|
|
||||||
import { splitHighlightSegments } from "./highlight"
|
import { splitHighlightSegments } from "./highlight"
|
||||||
import { searchMessages } from "./messages"
|
import { searchMessages } from "./messages"
|
||||||
@@ -124,9 +124,9 @@ function SearchResultRow({
|
|||||||
<HighlightedText text={item.description} query={query} />
|
<HighlightedText text={item.description} query={query} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.meta?.map((line, index) => (
|
{item.meta?.map((line) => (
|
||||||
<span
|
<span
|
||||||
key={`${line}:${index}`}
|
key={line}
|
||||||
className="block truncate text-xs text-muted-foreground"
|
className="block truncate text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<HighlightedText text={line} query={query} />
|
<HighlightedText text={line} query={query} />
|
||||||
@@ -158,16 +158,18 @@ function ResultMedia({ item }: { item: SearchResultItem }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||||
return splitHighlightSegments(text, query).map((segment, index) =>
|
let offset = 0
|
||||||
segment.highlighted ? (
|
|
||||||
<mark
|
return splitHighlightSegments(text, query).map((segment) => {
|
||||||
key={index}
|
const key = `${offset}:${segment.highlighted ? "highlight" : "text"}`
|
||||||
className="rounded-sm bg-primary/15 px-0.5 text-primary"
|
offset += segment.text.length
|
||||||
>
|
|
||||||
|
return segment.highlighted ? (
|
||||||
|
<mark key={key} className="rounded-sm bg-primary/15 px-0.5 text-primary">
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</mark>
|
</mark>
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment key={index}>{segment.text}</React.Fragment>
|
<React.Fragment key={key}>{segment.text}</React.Fragment>
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
+3
-3
@@ -14,7 +14,7 @@ import { SearchProvider } from "./context"
|
|||||||
import { SearchDialog } from "./dialog"
|
import { SearchDialog } from "./dialog"
|
||||||
import { searchDialogHandle } from "./handle"
|
import { searchDialogHandle } from "./handle"
|
||||||
import { splitHighlightSegments } from "./highlight"
|
import { splitHighlightSegments } from "./highlight"
|
||||||
import { messages as englishMessages } from "./locales/en"
|
import { messages as englishMessages } from "./locales/en-US"
|
||||||
import type { SearchAdapter } from "./types"
|
import type { SearchAdapter } from "./types"
|
||||||
|
|
||||||
class ResizeObserverStub {
|
class ResizeObserverStub {
|
||||||
@@ -37,7 +37,7 @@ function renderSearch({
|
|||||||
onSelect?: (event: { item: { id: string }; query: string }) => void
|
onSelect?: (event: { item: { id: string }; query: string }) => void
|
||||||
}) {
|
}) {
|
||||||
return render(
|
return render(
|
||||||
<I18nProvider locale="en" catalogs={{ en: englishMessages }}>
|
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||||
<SearchProvider
|
<SearchProvider
|
||||||
adapter={adapter}
|
adapter={adapter}
|
||||||
historyStorageKey={false}
|
historyStorageKey={false}
|
||||||
@@ -49,7 +49,7 @@ function renderSearch({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("search blocks", () => {
|
describe("search package", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@workspace/ui/components/empty"
|
} from "@workspace/ui/components/empty"
|
||||||
import { CircleAlertIcon, LoaderCircleIcon, SearchIcon } from "lucide-react"
|
import {
|
||||||
|
CircleAlertIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@workspace/ui/components/icon"
|
||||||
|
|
||||||
import { searchMessages } from "./messages"
|
import { searchMessages } from "./messages"
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@source "../";
|
||||||
@@ -46,7 +46,7 @@ export interface SearchRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only data dependency of the search blocks. Implement this in the host
|
* The only data dependency of the search package. Implement this in the host
|
||||||
* application with its own database, HTTP client, command registry, or index.
|
* application with its own database, HTTP client, command registry, or index.
|
||||||
*/
|
*/
|
||||||
export interface SearchAdapter {
|
export interface SearchAdapter {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import type { SearchCursor, SearchPage, SearchResultGroup } from "./types"
|
import type { SearchPage, SearchResultGroup } from "./types"
|
||||||
import { useSearchContext } from "./context"
|
import { useSearchContext } from "./context"
|
||||||
|
|
||||||
interface UseSearchOptions {
|
interface UseSearchOptions {
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@workspace/i18n": ["../i18n/src/index.ts"],
|
||||||
|
"@workspace/ui/*": ["../ui/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user