@echothink-ui/search 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -0
  2. package/dist/components/AgentSearchSuggestion.d.ts +2 -0
  3. package/dist/components/AppCommandSearch.d.ts +2 -0
  4. package/dist/components/AppDomainSearch.d.ts +2 -0
  5. package/dist/components/EntitySearchInput.d.ts +4 -0
  6. package/dist/components/GlobalCommandPalette.d.ts +4 -0
  7. package/dist/components/ProjectCommandPalette.d.ts +2 -0
  8. package/dist/components/RecentItems.d.ts +2 -0
  9. package/dist/components/ResourceSearch.d.ts +2 -0
  10. package/dist/components/SavedSearches.d.ts +2 -0
  11. package/dist/components/SearchFacetPanel.d.ts +2 -0
  12. package/dist/components/SearchResultsPanel.d.ts +2 -0
  13. package/dist/components/SemanticSearchResult.d.ts +2 -0
  14. package/dist/components/searchUtils.d.ts +3 -0
  15. package/dist/components/types.d.ts +175 -0
  16. package/dist/index.cjs +1224 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.css +1460 -0
  19. package/dist/index.css.map +1 -0
  20. package/dist/index.d.ts +16 -0
  21. package/dist/index.js +1185 -0
  22. package/dist/index.js.map +1 -0
  23. package/package.json +43 -0
  24. package/src/components/AgentSearchSuggestion.tsx +44 -0
  25. package/src/components/AppCommandSearch.tsx +163 -0
  26. package/src/components/AppDomainSearch.tsx +90 -0
  27. package/src/components/EntitySearchInput.tsx +165 -0
  28. package/src/components/GlobalCommandPalette.tsx +182 -0
  29. package/src/components/ProjectCommandPalette.tsx +24 -0
  30. package/src/components/RecentItems.tsx +136 -0
  31. package/src/components/ResourceSearch.tsx +27 -0
  32. package/src/components/SavedSearches.tsx +27 -0
  33. package/src/components/SearchFacetPanel.tsx +100 -0
  34. package/src/components/SearchResultsPanel.tsx +327 -0
  35. package/src/components/SemanticSearchResult.tsx +52 -0
  36. package/src/components/searchUtils.ts +20 -0
  37. package/src/components/types.ts +208 -0
  38. package/src/index.test.tsx +254 -0
  39. package/src/index.tsx +55 -0
  40. package/src/styles.css +1716 -0
@@ -0,0 +1,327 @@
1
+ import type * as React from "react";
2
+ import type {
3
+ SearchResultGroup,
4
+ SearchResultItem,
5
+ SearchResultMetadata,
6
+ SearchResultsPanelProps,
7
+ } from "./types";
8
+
9
+ function getGroupItems(group: SearchResultGroup) {
10
+ return group.results ?? group.items ?? [];
11
+ }
12
+
13
+ function getGroupLabel(group: SearchResultGroup) {
14
+ return (
15
+ group.label ?? group.title ?? group.entityType ?? group.type ?? "Results"
16
+ );
17
+ }
18
+
19
+ function getGroupDomId(group: SearchResultGroup, index: number) {
20
+ const seed = String(group.id ?? getGroupLabel(group))
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, "-")
23
+ .replace(/(^-|-$)/g, "");
24
+
25
+ return `search-results-${seed || "group"}-${index}`;
26
+ }
27
+
28
+ function getResultTitle(result: SearchResultItem) {
29
+ return result.title ?? result.name ?? result.label ?? "Untitled result";
30
+ }
31
+
32
+ function getResultDescription(result: SearchResultItem) {
33
+ return result.description ?? result.excerpt;
34
+ }
35
+
36
+ function getResultHref(result: SearchResultItem) {
37
+ return result.href ?? result.url;
38
+ }
39
+
40
+ function getResultId(
41
+ result: SearchResultItem,
42
+ group: SearchResultGroup,
43
+ index: number,
44
+ ) {
45
+ return (
46
+ result.id ??
47
+ `${group.id ?? getGroupLabel(group)}-${getResultTitle(result)}-${index}`
48
+ );
49
+ }
50
+
51
+ function getTotalCount(groups: SearchResultGroup[], totalCount?: number) {
52
+ if (typeof totalCount === "number") {
53
+ return totalCount;
54
+ }
55
+
56
+ return groups.reduce((sum, group) => sum + getGroupItems(group).length, 0);
57
+ }
58
+
59
+ function getEntityInitial(label: string) {
60
+ return label.trim().slice(0, 1).toUpperCase();
61
+ }
62
+
63
+ function isLabeledMetadata(
64
+ metadata: SearchResultMetadata,
65
+ ): metadata is { label?: React.ReactNode; value?: React.ReactNode } {
66
+ return (
67
+ typeof metadata === "object" &&
68
+ metadata !== null &&
69
+ !Array.isArray(metadata) &&
70
+ "value" in metadata
71
+ );
72
+ }
73
+
74
+ function normalizeMetadataValue(
75
+ metadata: SearchResultMetadata,
76
+ ): React.ReactNode {
77
+ if (isLabeledMetadata(metadata)) {
78
+ if (metadata.label && metadata.value !== undefined) {
79
+ return (
80
+ <>
81
+ {metadata.label}: {metadata.value}
82
+ </>
83
+ );
84
+ }
85
+
86
+ return metadata.value;
87
+ }
88
+
89
+ return metadata as React.ReactNode;
90
+ }
91
+
92
+ function hasMetadataSourceValue(
93
+ metadata: unknown,
94
+ ): metadata is SearchResultMetadata {
95
+ return (
96
+ metadata !== null &&
97
+ metadata !== undefined &&
98
+ metadata !== false &&
99
+ metadata !== ""
100
+ );
101
+ }
102
+
103
+ function hasRenderableMetadata(metadata: React.ReactNode) {
104
+ return (
105
+ metadata !== null &&
106
+ metadata !== undefined &&
107
+ metadata !== false &&
108
+ metadata !== ""
109
+ );
110
+ }
111
+
112
+ function getMetadata(result: SearchResultItem): React.ReactNode[] {
113
+ const source = result.meta ?? result.metadata;
114
+ const values: SearchResultMetadata[] = Array.isArray(source)
115
+ ? [...source]
116
+ : [];
117
+
118
+ if (!Array.isArray(source) && hasMetadataSourceValue(source)) {
119
+ values.push(source);
120
+ }
121
+
122
+ if (result.owner) {
123
+ values.push({ label: "Owner", value: result.owner });
124
+ }
125
+
126
+ if (result.status) {
127
+ values.push(result.status);
128
+ }
129
+
130
+ if (result.updatedAt) {
131
+ values.push({ label: "Updated", value: result.updatedAt });
132
+ }
133
+
134
+ if (result.version) {
135
+ values.push(`v${result.version}`);
136
+ }
137
+
138
+ return values.map(normalizeMetadataValue).filter(hasRenderableMetadata);
139
+ }
140
+
141
+ export function SearchResultsPanel({
142
+ groups = [],
143
+ query,
144
+ totalCount,
145
+ isLoading = false,
146
+ emptyLabel = "No results found",
147
+ className,
148
+ onResultSelect,
149
+ onSelect,
150
+ ...props
151
+ }: SearchResultsPanelProps) {
152
+ const count = getTotalCount(groups, totalCount);
153
+ const hasResults = groups.some((group) => getGroupItems(group).length > 0);
154
+ const classes = ["eth-search-results-panel", className]
155
+ .filter(Boolean)
156
+ .join(" ");
157
+
158
+ const handleSelect = (
159
+ result: SearchResultItem,
160
+ group: SearchResultGroup,
161
+ resultId: string,
162
+ ) => {
163
+ onResultSelect?.(result, group);
164
+ onSelect?.(result.id ?? resultId);
165
+ };
166
+
167
+ return (
168
+ <section
169
+ {...props}
170
+ className={classes}
171
+ aria-label={props["aria-label"] ?? "Search results"}
172
+ data-eth-component="SearchResultsPanel"
173
+ >
174
+ <header className="eth-search-results-panel__summary">
175
+ <div>
176
+ <p className="eth-search-results-panel__eyebrow">Search results</p>
177
+ <h3 className="eth-search-results-panel__title">
178
+ {isLoading
179
+ ? "Searching..."
180
+ : `${count} result${count === 1 ? "" : "s"}`}
181
+ </h3>
182
+ </div>
183
+ {query ? (
184
+ <span className="eth-search-results-panel__query">"{query}"</span>
185
+ ) : null}
186
+ </header>
187
+
188
+ {isLoading ? (
189
+ <div className="eth-search-results-panel__state" role="status">
190
+ <p className="eth-search-results-panel__loading-copy">
191
+ Loading results
192
+ </p>
193
+ <span className="eth-search-results-panel__skeleton" />
194
+ <span className="eth-search-results-panel__skeleton" />
195
+ <span className="eth-search-results-panel__skeleton" />
196
+ </div>
197
+ ) : hasResults ? (
198
+ <div className="eth-search-results-panel__groups">
199
+ {groups.map((group, groupIndex) => {
200
+ const items = getGroupItems(group);
201
+ const groupLabel = getGroupLabel(group);
202
+ const groupId = getGroupDomId(group, groupIndex);
203
+
204
+ if (items.length === 0) {
205
+ return null;
206
+ }
207
+
208
+ return (
209
+ <section
210
+ className="eth-search-results-panel__group"
211
+ key={group.id ?? `${groupLabel}-${groupIndex}`}
212
+ aria-labelledby={groupId}
213
+ >
214
+ <div className="eth-search-results-panel__group-header">
215
+ <h4
216
+ className="eth-search-results-panel__group-title"
217
+ id={groupId}
218
+ >
219
+ {groupLabel}
220
+ </h4>
221
+ <span className="eth-search-results-panel__count">
222
+ {group.count ?? items.length}
223
+ </span>
224
+ </div>
225
+
226
+ <div className="eth-search-results-panel__list">
227
+ {items.map((result, index) => {
228
+ const metadata = getMetadata(result);
229
+ const resultId = getResultId(result, group, index);
230
+ const description = getResultDescription(result);
231
+ const href = getResultHref(result);
232
+ const title = getResultTitle(result);
233
+ const resultContent = (
234
+ <>
235
+ <span
236
+ className="eth-search-results-panel__entity"
237
+ aria-hidden="true"
238
+ >
239
+ {getEntityInitial(
240
+ result.entityType ??
241
+ result.type ??
242
+ group.entityType ??
243
+ groupLabel,
244
+ )}
245
+ </span>
246
+ <span className="eth-search-results-panel__body">
247
+ <span className="eth-search-results-panel__name">
248
+ {title}
249
+ </span>
250
+ {description ? (
251
+ <span className="eth-search-results-panel__description">
252
+ {description}
253
+ </span>
254
+ ) : null}
255
+ </span>
256
+ {metadata.length > 0 ? (
257
+ <span className="eth-search-results-panel__metadata">
258
+ {metadata.map((item, metaIndex) => (
259
+ <span
260
+ className="eth-search-results-panel__meta"
261
+ key={metaIndex}
262
+ >
263
+ {item}
264
+ </span>
265
+ ))}
266
+ </span>
267
+ ) : null}
268
+ </>
269
+ );
270
+
271
+ if (href) {
272
+ return (
273
+ <a
274
+ className="eth-search-results-panel__item"
275
+ href={href}
276
+ key={resultId}
277
+ onClick={
278
+ onResultSelect || onSelect
279
+ ? () => handleSelect(result, group, resultId)
280
+ : undefined
281
+ }
282
+ >
283
+ {resultContent}
284
+ </a>
285
+ );
286
+ }
287
+
288
+ if (onResultSelect || onSelect) {
289
+ return (
290
+ <button
291
+ className="eth-search-results-panel__item"
292
+ key={resultId}
293
+ type="button"
294
+ onClick={() => handleSelect(result, group, resultId)}
295
+ >
296
+ {resultContent}
297
+ </button>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <article
303
+ className="eth-search-results-panel__item"
304
+ key={resultId}
305
+ >
306
+ {resultContent}
307
+ </article>
308
+ );
309
+ })}
310
+ </div>
311
+ </section>
312
+ );
313
+ })}
314
+ </div>
315
+ ) : (
316
+ <div className="eth-search-results-panel__state">
317
+ <p className="eth-search-results-panel__empty-title">{emptyLabel}</p>
318
+ {query ? (
319
+ <p className="eth-search-results-panel__empty-copy">
320
+ Try another keyword or remove a filter.
321
+ </p>
322
+ ) : null}
323
+ </div>
324
+ )}
325
+ </section>
326
+ );
327
+ }
@@ -0,0 +1,52 @@
1
+ import * as React from "react";
2
+ import type { SemanticSearchResultProps } from "./types";
3
+
4
+ export function SemanticSearchResult({ result, className, ...props }: SemanticSearchResultProps) {
5
+ const evidence = result.evidence ?? [];
6
+
7
+ return (
8
+ <article
9
+ {...props}
10
+ className={`eth-search-semantic-result ${className ?? ""}`}
11
+ data-eth-component="SemanticSearchResult"
12
+ >
13
+ <p className="eth-search-semantic-result__snippet">
14
+ {renderHighlightedSnippet(result.snippet, result.highlights ?? [])}
15
+ </p>
16
+ {evidence.length > 0 ? (
17
+ <div className="eth-search-semantic-result__evidence">
18
+ <span className="eth-search-semantic-result__evidence-label">Evidence</span>
19
+ <ul className="eth-search-semantic-result__evidence-list">
20
+ {evidence.map((item) => (
21
+ <li key={item.id}>
22
+ {item.href ? (
23
+ <a className="eth-search-semantic-result__evidence-item" href={item.href}>
24
+ {item.label}
25
+ </a>
26
+ ) : (
27
+ <span className="eth-search-semantic-result__evidence-item">{item.label}</span>
28
+ )}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ </div>
33
+ ) : null}
34
+ </article>
35
+ );
36
+ }
37
+
38
+ function renderHighlightedSnippet(snippet: string, highlights: Array<[number, number]>) {
39
+ if (!highlights.length) return snippet;
40
+ const sorted = [...highlights].sort((a, b) => a[0] - b[0]);
41
+ const parts: React.ReactNode[] = [];
42
+ let cursor = 0;
43
+ sorted.forEach(([start, end], index) => {
44
+ const safeStart = Math.max(cursor, Math.min(snippet.length, start));
45
+ const safeEnd = Math.max(safeStart, Math.min(snippet.length, end));
46
+ if (safeStart > cursor) parts.push(snippet.slice(cursor, safeStart));
47
+ parts.push(<mark key={index}>{snippet.slice(safeStart, safeEnd)}</mark>);
48
+ cursor = safeEnd;
49
+ });
50
+ if (cursor < snippet.length) parts.push(snippet.slice(cursor));
51
+ return parts;
52
+ }
@@ -0,0 +1,20 @@
1
+ import type { CommandItem } from "./types";
2
+
3
+ export function filterCommands(commands: CommandItem[], query: string) {
4
+ const normalized = query.trim().toLowerCase();
5
+ if (!normalized) return commands;
6
+ return commands.filter((command) =>
7
+ [command.label, command.hint, command.group, command.shortcut]
8
+ .filter(Boolean)
9
+ .some((value) => String(value).toLowerCase().includes(normalized))
10
+ );
11
+ }
12
+
13
+ export function groupBy<T>(items: T[], getKey: (item: T) => string) {
14
+ const groups = new Map<string, T[]>();
15
+ items.forEach((item) => {
16
+ const key = getKey(item);
17
+ groups.set(key, [...(groups.get(key) ?? []), item]);
18
+ });
19
+ return Array.from(groups.entries());
20
+ }
@@ -0,0 +1,208 @@
1
+ import type * as React from "react";
2
+
3
+ export interface CommandItem {
4
+ id: string;
5
+ label: string;
6
+ hint?: string;
7
+ group?: string;
8
+ shortcut?: string;
9
+ onSelect: () => void;
10
+ }
11
+
12
+ export type AppCommandKind = "app-domain" | "resource" | "action";
13
+
14
+ export interface AppCommandItem {
15
+ id: string;
16
+ label: string;
17
+ description?: React.ReactNode;
18
+ domain?: string;
19
+ kind?: AppCommandKind;
20
+ shortcut?: string;
21
+ status?: React.ReactNode;
22
+ onSelect?: () => void;
23
+ }
24
+
25
+ export interface AppCommandSearchProps extends Omit<
26
+ React.HTMLAttributes<HTMLElement>,
27
+ "defaultValue" | "onChange" | "onSelect" | "onSubmit"
28
+ > {
29
+ value?: string;
30
+ defaultValue?: string;
31
+ placeholder?: string;
32
+ commands?: AppCommandItem[];
33
+ kbdHint?: React.ReactNode;
34
+ scopeLabel?: React.ReactNode;
35
+ emptyText?: React.ReactNode;
36
+ onChange?: (value: string) => void;
37
+ onSelect?: (id: string) => void;
38
+ onSubmit?: (value: string) => void;
39
+ }
40
+
41
+ export interface GlobalCommandPaletteProps extends Omit<
42
+ React.HTMLAttributes<HTMLElement>,
43
+ "onSelect"
44
+ > {
45
+ open: boolean;
46
+ onClose: () => void;
47
+ commands: CommandItem[];
48
+ heading?: React.ReactNode;
49
+ contextLabel?: React.ReactNode;
50
+ query?: string;
51
+ searchPlaceholder?: string;
52
+ onQueryChange?: (query: string) => void;
53
+ }
54
+
55
+ export interface ProjectCommandPaletteProps extends GlobalCommandPaletteProps {
56
+ projectRef: string;
57
+ }
58
+
59
+ export type SearchResultMetadata =
60
+ | React.ReactNode
61
+ | {
62
+ label?: React.ReactNode;
63
+ value?: React.ReactNode;
64
+ };
65
+
66
+ export interface SearchResultItem {
67
+ id?: string;
68
+ label?: string;
69
+ title?: string;
70
+ name?: string;
71
+ description?: React.ReactNode;
72
+ excerpt?: React.ReactNode;
73
+ href?: string;
74
+ url?: string;
75
+ entityType?: string;
76
+ type?: string;
77
+ meta?: SearchResultMetadata | SearchResultMetadata[];
78
+ metadata?: SearchResultMetadata | SearchResultMetadata[];
79
+ owner?: React.ReactNode;
80
+ status?: React.ReactNode;
81
+ updatedAt?: React.ReactNode;
82
+ version?: string | number;
83
+ }
84
+
85
+ export interface SearchResultGroup {
86
+ id?: string;
87
+ label?: string;
88
+ title?: string;
89
+ type?: string;
90
+ entityType?: string;
91
+ count?: number;
92
+ results?: SearchResultItem[];
93
+ items?: SearchResultItem[];
94
+ }
95
+
96
+ export interface SearchResultsPanelProps extends Omit<
97
+ React.HTMLAttributes<HTMLElement>,
98
+ "onSelect"
99
+ > {
100
+ groups?: SearchResultGroup[];
101
+ query?: string;
102
+ totalCount?: number;
103
+ isLoading?: boolean;
104
+ emptyLabel?: React.ReactNode;
105
+ onResultSelect?: (result: SearchResultItem, group: SearchResultGroup) => void;
106
+ onSelect?: (id: string) => void;
107
+ }
108
+
109
+ export interface SearchFacetOption {
110
+ value: string;
111
+ label: string;
112
+ count?: number;
113
+ disabled?: boolean;
114
+ }
115
+
116
+ export interface SearchFacet {
117
+ id: string;
118
+ label: string;
119
+ options: SearchFacetOption[];
120
+ }
121
+
122
+ export interface SearchFacetPanelProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange"> {
123
+ facets: SearchFacet[];
124
+ selected: Record<string, string[]>;
125
+ onChange: (selected: Record<string, string[]>) => void;
126
+ }
127
+
128
+ export interface SavedSearch extends Record<string, unknown> {
129
+ id: string;
130
+ label: string;
131
+ query: string;
132
+ updatedAt: string;
133
+ }
134
+
135
+ export interface SavedSearchesProps extends React.HTMLAttributes<HTMLElement> {
136
+ searches: SavedSearch[];
137
+ onRun?: (id: string) => void;
138
+ onDelete?: (id: string) => void;
139
+ }
140
+
141
+ export interface RecentItem extends Record<string, unknown> {
142
+ id: string;
143
+ label: string;
144
+ kind: string;
145
+ description?: React.ReactNode;
146
+ href?: string;
147
+ visitedAt: string;
148
+ }
149
+
150
+ export interface RecentItemsProps extends Omit<React.HTMLAttributes<HTMLElement>, "onSelect"> {
151
+ items: RecentItem[];
152
+ emptyText?: React.ReactNode;
153
+ onSelect?: (id: string) => void;
154
+ }
155
+
156
+ export interface EntitySuggestion {
157
+ id: string;
158
+ label: string;
159
+ kind?: string;
160
+ meta?: React.ReactNode;
161
+ }
162
+
163
+ export interface EntitySearchInputProps extends Omit<
164
+ React.HTMLAttributes<HTMLElement>,
165
+ "defaultValue" | "onChange" | "onSelect"
166
+ > {
167
+ placeholder?: string;
168
+ value?: string;
169
+ defaultValue?: string;
170
+ onSearch?: (q: string) => void;
171
+ suggestions?: EntitySuggestion[];
172
+ suggestionsLabel?: string;
173
+ resultsLabel?: React.ReactNode;
174
+ loadingLabel?: React.ReactNode;
175
+ open?: boolean;
176
+ defaultOpen?: boolean;
177
+ loading?: boolean;
178
+ emptyText?: React.ReactNode;
179
+ onChange?: (value: string) => void;
180
+ onOpenChange?: (open: boolean) => void;
181
+ onSelect?: (suggestion: EntitySuggestion) => void;
182
+ }
183
+
184
+ export interface AppDomainSearchProps extends EntitySearchInputProps {
185
+ appDomainRef?: string;
186
+ }
187
+
188
+ export interface ResourceSearchProps extends EntitySearchInputProps {
189
+ resourceScope?: string;
190
+ }
191
+
192
+ export interface SemanticSearchResultProps extends React.HTMLAttributes<HTMLElement> {
193
+ result: {
194
+ id: string;
195
+ snippet: string;
196
+ highlights?: Array<[number, number]>;
197
+ evidence: Array<{ id: string; label: string; href?: string }>;
198
+ };
199
+ }
200
+
201
+ export interface AgentSearchSuggestionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
202
+ suggestion: {
203
+ id: string;
204
+ label: string;
205
+ rationale?: string;
206
+ onSelect?: () => void;
207
+ };
208
+ }