@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.
- package/README.md +5 -0
- package/dist/components/AgentSearchSuggestion.d.ts +2 -0
- package/dist/components/AppCommandSearch.d.ts +2 -0
- package/dist/components/AppDomainSearch.d.ts +2 -0
- package/dist/components/EntitySearchInput.d.ts +4 -0
- package/dist/components/GlobalCommandPalette.d.ts +4 -0
- package/dist/components/ProjectCommandPalette.d.ts +2 -0
- package/dist/components/RecentItems.d.ts +2 -0
- package/dist/components/ResourceSearch.d.ts +2 -0
- package/dist/components/SavedSearches.d.ts +2 -0
- package/dist/components/SearchFacetPanel.d.ts +2 -0
- package/dist/components/SearchResultsPanel.d.ts +2 -0
- package/dist/components/SemanticSearchResult.d.ts +2 -0
- package/dist/components/searchUtils.d.ts +3 -0
- package/dist/components/types.d.ts +175 -0
- package/dist/index.cjs +1224 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +1460 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +1185 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/components/AgentSearchSuggestion.tsx +44 -0
- package/src/components/AppCommandSearch.tsx +163 -0
- package/src/components/AppDomainSearch.tsx +90 -0
- package/src/components/EntitySearchInput.tsx +165 -0
- package/src/components/GlobalCommandPalette.tsx +182 -0
- package/src/components/ProjectCommandPalette.tsx +24 -0
- package/src/components/RecentItems.tsx +136 -0
- package/src/components/ResourceSearch.tsx +27 -0
- package/src/components/SavedSearches.tsx +27 -0
- package/src/components/SearchFacetPanel.tsx +100 -0
- package/src/components/SearchResultsPanel.tsx +327 -0
- package/src/components/SemanticSearchResult.tsx +52 -0
- package/src/components/searchUtils.ts +20 -0
- package/src/components/types.ts +208 -0
- package/src/index.test.tsx +254 -0
- package/src/index.tsx +55 -0
- package/src/styles.css +1716 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { SearchInput } from "@echothink-ui/core";
|
|
3
|
+
import type { EntitySearchInputProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export function EntitySearchInput({
|
|
6
|
+
placeholder = "Search",
|
|
7
|
+
onSearch,
|
|
8
|
+
suggestions = [],
|
|
9
|
+
value,
|
|
10
|
+
defaultValue = "",
|
|
11
|
+
onChange,
|
|
12
|
+
onSelect,
|
|
13
|
+
suggestionsLabel = "Entity suggestions",
|
|
14
|
+
resultsLabel = "Entity matches",
|
|
15
|
+
loadingLabel = "Searching entities",
|
|
16
|
+
open,
|
|
17
|
+
defaultOpen = false,
|
|
18
|
+
loading = false,
|
|
19
|
+
emptyText = "No matching entities",
|
|
20
|
+
onOpenChange,
|
|
21
|
+
className,
|
|
22
|
+
onBlur,
|
|
23
|
+
onKeyDown,
|
|
24
|
+
"data-eth-component": dataEthComponent = "EntitySearchInput",
|
|
25
|
+
...props
|
|
26
|
+
}: EntitySearchInputProps & { "data-eth-component"?: string }) {
|
|
27
|
+
const listboxId = React.useId().replace(/:/g, "");
|
|
28
|
+
const [internalValue, setInternalValue] = React.useState(defaultValue);
|
|
29
|
+
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
30
|
+
const [selectedId, setSelectedId] = React.useState<string | undefined>(() => {
|
|
31
|
+
const initialValue = value ?? defaultValue;
|
|
32
|
+
return suggestions.find((suggestion) => suggestion.id === initialValue)?.id;
|
|
33
|
+
});
|
|
34
|
+
const currentValue = value ?? internalValue;
|
|
35
|
+
const selectedSuggestion =
|
|
36
|
+
suggestions.find((suggestion) => suggestion.id === currentValue) ??
|
|
37
|
+
suggestions.find((suggestion) => suggestion.id === selectedId);
|
|
38
|
+
const displayValue =
|
|
39
|
+
selectedSuggestion?.id === currentValue ? selectedSuggestion.label : currentValue;
|
|
40
|
+
const isOpen = open ?? internalOpen;
|
|
41
|
+
|
|
42
|
+
const setOpen = (nextOpen: boolean) => {
|
|
43
|
+
if (open === undefined) {
|
|
44
|
+
setInternalOpen(nextOpen);
|
|
45
|
+
}
|
|
46
|
+
onOpenChange?.(nextOpen);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const update = (nextValue: string) => {
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
setInternalValue(nextValue);
|
|
52
|
+
}
|
|
53
|
+
setSelectedId(undefined);
|
|
54
|
+
onChange?.(nextValue);
|
|
55
|
+
onSearch?.(nextValue);
|
|
56
|
+
setOpen(Boolean(nextValue || suggestions.length || loading));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const choose = (suggestion: (typeof suggestions)[number]) => {
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
setInternalValue(suggestion.label);
|
|
62
|
+
}
|
|
63
|
+
setSelectedId(suggestion.id);
|
|
64
|
+
onChange?.(suggestion.id);
|
|
65
|
+
onSearch?.(suggestion.label);
|
|
66
|
+
onSelect?.(suggestion);
|
|
67
|
+
setOpen(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const clear = () => {
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
setInternalValue("");
|
|
73
|
+
}
|
|
74
|
+
setSelectedId(undefined);
|
|
75
|
+
onChange?.("");
|
|
76
|
+
onSearch?.("");
|
|
77
|
+
setOpen(false);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleBlur = (event: React.FocusEvent<HTMLDivElement>) => {
|
|
81
|
+
onBlur?.(event);
|
|
82
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
83
|
+
setOpen(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
88
|
+
onKeyDown?.(event);
|
|
89
|
+
if (event.defaultPrevented) return;
|
|
90
|
+
if (event.key === "Escape") {
|
|
91
|
+
setOpen(false);
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
}
|
|
94
|
+
if (event.key === "ArrowDown" && suggestions.length) {
|
|
95
|
+
setOpen(true);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
{...props}
|
|
102
|
+
className={`eth-search-entity-input ${className ?? ""}`}
|
|
103
|
+
data-eth-component={dataEthComponent}
|
|
104
|
+
onBlur={handleBlur}
|
|
105
|
+
onKeyDown={handleKeyDown}
|
|
106
|
+
>
|
|
107
|
+
<SearchInput
|
|
108
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
109
|
+
aria-expanded={isOpen}
|
|
110
|
+
aria-haspopup="listbox"
|
|
111
|
+
autoComplete="off"
|
|
112
|
+
className="eth-search-entity-input__field"
|
|
113
|
+
value={displayValue}
|
|
114
|
+
placeholder={placeholder}
|
|
115
|
+
onFocus={() => setOpen(Boolean(suggestions.length || loading))}
|
|
116
|
+
onChange={(event) => update(event.currentTarget.value)}
|
|
117
|
+
onClear={clear}
|
|
118
|
+
/>
|
|
119
|
+
{isOpen ? (
|
|
120
|
+
<div
|
|
121
|
+
className="eth-search-entity-input__popover"
|
|
122
|
+
id={listboxId}
|
|
123
|
+
role="listbox"
|
|
124
|
+
aria-label={suggestionsLabel}
|
|
125
|
+
>
|
|
126
|
+
<div className="eth-search-entity-input__summary" aria-live="polite">
|
|
127
|
+
<span>{loading ? loadingLabel : resultsLabel}</span>
|
|
128
|
+
<strong>
|
|
129
|
+
{loading
|
|
130
|
+
? "Loading"
|
|
131
|
+
: `${suggestions.length} result${suggestions.length === 1 ? "" : "s"}`}
|
|
132
|
+
</strong>
|
|
133
|
+
</div>
|
|
134
|
+
{suggestions.length ? (
|
|
135
|
+
<ul className="eth-search-entity-input__list">
|
|
136
|
+
{suggestions.map((suggestion) => {
|
|
137
|
+
const isSelected = suggestion.id === selectedSuggestion?.id;
|
|
138
|
+
return (
|
|
139
|
+
<li key={suggestion.id}>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
role="option"
|
|
143
|
+
aria-selected={isSelected}
|
|
144
|
+
onClick={() => choose(suggestion)}
|
|
145
|
+
>
|
|
146
|
+
<span className="eth-search-entity-input__copy">
|
|
147
|
+
<strong>{suggestion.label}</strong>
|
|
148
|
+
{suggestion.kind ? <small>{suggestion.kind}</small> : null}
|
|
149
|
+
</span>
|
|
150
|
+
<span className="eth-search-entity-input__meta">
|
|
151
|
+
{isSelected ? "Selected" : suggestion.meta}
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
</li>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</ul>
|
|
158
|
+
) : (
|
|
159
|
+
<p className="eth-search-entity-input__empty">{loading ? "Loading..." : emptyText}</p>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { SearchInput } from "@echothink-ui/core";
|
|
3
|
+
import { CloseIcon } from "@echothink-ui/icons";
|
|
4
|
+
import type { CommandItem, GlobalCommandPaletteProps } from "./types";
|
|
5
|
+
import { filterCommands, groupBy } from "./searchUtils";
|
|
6
|
+
|
|
7
|
+
export function GlobalCommandPalette({
|
|
8
|
+
open,
|
|
9
|
+
onClose,
|
|
10
|
+
commands,
|
|
11
|
+
heading = "Global command palette",
|
|
12
|
+
contextLabel,
|
|
13
|
+
query,
|
|
14
|
+
searchPlaceholder = "Search commands",
|
|
15
|
+
onQueryChange,
|
|
16
|
+
className,
|
|
17
|
+
"data-eth-component": dataEthComponent = "GlobalCommandPalette",
|
|
18
|
+
...props
|
|
19
|
+
}: GlobalCommandPaletteProps & { "data-eth-component"?: string }) {
|
|
20
|
+
const [internalQuery, setInternalQuery] = React.useState("");
|
|
21
|
+
const [activeIndex, setActiveIndex] = React.useState(0);
|
|
22
|
+
const generatedId = React.useId().replace(/:/g, "");
|
|
23
|
+
const searchRef = React.useRef<HTMLDivElement>(null);
|
|
24
|
+
const currentQuery = query ?? internalQuery;
|
|
25
|
+
const filtered = React.useMemo(
|
|
26
|
+
() => filterCommands(commands, currentQuery),
|
|
27
|
+
[commands, currentQuery]
|
|
28
|
+
);
|
|
29
|
+
const grouped = groupBy(filtered, (command) => command.group ?? "Commands");
|
|
30
|
+
const titleId = `eth-search-command-palette-title-${generatedId}`;
|
|
31
|
+
const listboxId = `eth-search-command-palette-listbox-${generatedId}`;
|
|
32
|
+
const activeOptionId = filtered.length ? `${listboxId}-option-${activeIndex}` : undefined;
|
|
33
|
+
const resultCountLabel = `${filtered.length} ${filtered.length === 1 ? "command" : "commands"} ${
|
|
34
|
+
currentQuery.trim() ? "matched" : "available"
|
|
35
|
+
}`;
|
|
36
|
+
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
if (!open) return;
|
|
39
|
+
setActiveIndex(0);
|
|
40
|
+
const input = searchRef.current?.querySelector("input");
|
|
41
|
+
input?.focus();
|
|
42
|
+
}, [open]);
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
setActiveIndex((index) => Math.min(index, Math.max(0, filtered.length - 1)));
|
|
46
|
+
}, [filtered.length]);
|
|
47
|
+
|
|
48
|
+
if (!open) return null;
|
|
49
|
+
|
|
50
|
+
const setQuery = (nextQuery: string) => {
|
|
51
|
+
setInternalQuery(nextQuery);
|
|
52
|
+
onQueryChange?.(nextQuery);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const selectCommand = (command: CommandItem) => {
|
|
56
|
+
command.onSelect();
|
|
57
|
+
onClose();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
61
|
+
if (event.key === "Escape") {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
onClose();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (event.key === "ArrowDown") {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
if (!filtered.length) return;
|
|
69
|
+
setActiveIndex((index) => Math.min(filtered.length - 1, index + 1));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (event.key === "ArrowUp") {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
if (!filtered.length) return;
|
|
75
|
+
setActiveIndex((index) => Math.max(0, index - 1));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (event.key === "Enter") {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
const command = filtered[activeIndex];
|
|
81
|
+
if (command) selectCommand(command);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let flatIndex = 0;
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
{...props}
|
|
89
|
+
className={`eth-search-command-palette ${className ?? ""}`}
|
|
90
|
+
data-eth-component={dataEthComponent}
|
|
91
|
+
role="dialog"
|
|
92
|
+
aria-modal="true"
|
|
93
|
+
aria-labelledby={titleId}
|
|
94
|
+
onKeyDown={onKeyDown}
|
|
95
|
+
>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="eth-search-command-palette__scrim"
|
|
99
|
+
aria-label="Close command palette"
|
|
100
|
+
onClick={onClose}
|
|
101
|
+
/>
|
|
102
|
+
<section className="eth-search-command-palette__panel" aria-labelledby={titleId}>
|
|
103
|
+
<header className="eth-search-command-palette__header">
|
|
104
|
+
<div className="eth-search-command-palette__heading">
|
|
105
|
+
<h2 id={titleId}>{heading}</h2>
|
|
106
|
+
<div className="eth-search-command-palette__meta">
|
|
107
|
+
{contextLabel ? <span>{contextLabel}</span> : null}
|
|
108
|
+
<span aria-live="polite">{resultCountLabel}</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className="eth-search-command-palette__close"
|
|
114
|
+
aria-label="Close command palette"
|
|
115
|
+
onClick={onClose}
|
|
116
|
+
>
|
|
117
|
+
<CloseIcon size={16} />
|
|
118
|
+
</button>
|
|
119
|
+
</header>
|
|
120
|
+
<div ref={searchRef} className="eth-search-command-palette__search">
|
|
121
|
+
<SearchInput
|
|
122
|
+
value={currentQuery}
|
|
123
|
+
placeholder={searchPlaceholder}
|
|
124
|
+
aria-controls={listboxId}
|
|
125
|
+
aria-activedescendant={activeOptionId}
|
|
126
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
127
|
+
onClear={() => setQuery("")}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
<div
|
|
131
|
+
id={listboxId}
|
|
132
|
+
className="eth-search-command-palette__results"
|
|
133
|
+
role="listbox"
|
|
134
|
+
aria-label="Commands"
|
|
135
|
+
>
|
|
136
|
+
{grouped.map(([group, items], groupIndex) => {
|
|
137
|
+
const groupHeadingId = `${listboxId}-group-${groupIndex}`;
|
|
138
|
+
return (
|
|
139
|
+
<section
|
|
140
|
+
key={group}
|
|
141
|
+
className="eth-search-command-palette__group"
|
|
142
|
+
role="group"
|
|
143
|
+
aria-labelledby={groupHeadingId}
|
|
144
|
+
>
|
|
145
|
+
<h3 id={groupHeadingId}>{group}</h3>
|
|
146
|
+
{items.map((command) => {
|
|
147
|
+
const index = flatIndex;
|
|
148
|
+
flatIndex += 1;
|
|
149
|
+
const active = index === activeIndex;
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
key={command.id}
|
|
153
|
+
id={`${listboxId}-option-${index}`}
|
|
154
|
+
type="button"
|
|
155
|
+
role="option"
|
|
156
|
+
aria-selected={active}
|
|
157
|
+
className={`eth-search-command-palette__item ${active ? "eth-search-command-palette__item--active" : ""}`}
|
|
158
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
159
|
+
onClick={() => selectCommand(command)}
|
|
160
|
+
>
|
|
161
|
+
<span className="eth-search-command-palette__item-copy">
|
|
162
|
+
<strong>{command.label}</strong>
|
|
163
|
+
{command.hint ? <small>{command.hint}</small> : null}
|
|
164
|
+
</span>
|
|
165
|
+
{command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
|
|
166
|
+
</button>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</section>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
{!filtered.length ? (
|
|
173
|
+
<div className="eth-search-command-palette__empty" role="status">
|
|
174
|
+
<strong>No commands found</strong>
|
|
175
|
+
<span>Try a different command, resource, or setting.</span>
|
|
176
|
+
</div>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
</section>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { GlobalCommandPalette } from "./GlobalCommandPalette";
|
|
3
|
+
import type { ProjectCommandPaletteProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export function ProjectCommandPalette({
|
|
6
|
+
projectRef,
|
|
7
|
+
className,
|
|
8
|
+
contextLabel = `Project ${projectRef}`,
|
|
9
|
+
heading = "Project command palette",
|
|
10
|
+
searchPlaceholder = "Search project commands",
|
|
11
|
+
...props
|
|
12
|
+
}: ProjectCommandPaletteProps) {
|
|
13
|
+
return (
|
|
14
|
+
<GlobalCommandPalette
|
|
15
|
+
{...props}
|
|
16
|
+
contextLabel={contextLabel}
|
|
17
|
+
heading={heading}
|
|
18
|
+
className={`eth-search-project-command-palette ${className ?? ""}`}
|
|
19
|
+
data-project-ref={projectRef}
|
|
20
|
+
data-eth-component="ProjectCommandPalette"
|
|
21
|
+
searchPlaceholder={searchPlaceholder}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
AgentRunningIcon,
|
|
4
|
+
ChevronRightIcon,
|
|
5
|
+
DocumentIcon,
|
|
6
|
+
FolderIcon,
|
|
7
|
+
MessageIcon,
|
|
8
|
+
PlayIcon,
|
|
9
|
+
SearchIcon,
|
|
10
|
+
StatusIcon,
|
|
11
|
+
TableIcon
|
|
12
|
+
} from "@echothink-ui/icons";
|
|
13
|
+
import type { RecentItemsProps } from "./types";
|
|
14
|
+
|
|
15
|
+
function formatRecentKind(kind: string) {
|
|
16
|
+
return kind
|
|
17
|
+
.split(/[-_\s]+/)
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
20
|
+
.join(" ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function RecentItemIcon({ kind }: { kind: string }) {
|
|
24
|
+
const normalizedKind = kind.toLowerCase();
|
|
25
|
+
|
|
26
|
+
if (normalizedKind.includes("agent")) return <AgentRunningIcon size={16} />;
|
|
27
|
+
if (normalizedKind.includes("document") || normalizedKind.includes("file")) {
|
|
28
|
+
return <DocumentIcon size={16} />;
|
|
29
|
+
}
|
|
30
|
+
if (normalizedKind.includes("project") || normalizedKind.includes("folder")) {
|
|
31
|
+
return <FolderIcon size={16} />;
|
|
32
|
+
}
|
|
33
|
+
if (normalizedKind.includes("mail") || normalizedKind.includes("message")) {
|
|
34
|
+
return <MessageIcon size={16} />;
|
|
35
|
+
}
|
|
36
|
+
if (normalizedKind.includes("dataset") || normalizedKind.includes("table")) {
|
|
37
|
+
return <TableIcon size={16} />;
|
|
38
|
+
}
|
|
39
|
+
if (normalizedKind.includes("action") || normalizedKind.includes("command")) {
|
|
40
|
+
return <PlayIcon size={16} />;
|
|
41
|
+
}
|
|
42
|
+
if (normalizedKind.includes("task") || normalizedKind.includes("wave")) {
|
|
43
|
+
return <StatusIcon size={16} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return <SearchIcon size={16} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function RecentItems({
|
|
50
|
+
items,
|
|
51
|
+
emptyText = "No recent items",
|
|
52
|
+
onSelect,
|
|
53
|
+
className,
|
|
54
|
+
"aria-label": ariaLabel = "Recent items",
|
|
55
|
+
...props
|
|
56
|
+
}: RecentItemsProps) {
|
|
57
|
+
const classNames = ["eth-search-recent-items", className].filter(Boolean).join(" ");
|
|
58
|
+
|
|
59
|
+
if (!items.length) {
|
|
60
|
+
return (
|
|
61
|
+
<section
|
|
62
|
+
{...props}
|
|
63
|
+
aria-label={ariaLabel}
|
|
64
|
+
className={classNames}
|
|
65
|
+
data-eth-component="RecentItems"
|
|
66
|
+
>
|
|
67
|
+
<div className="eth-search-recent-items__empty" role="status">
|
|
68
|
+
{emptyText}
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section
|
|
76
|
+
{...props}
|
|
77
|
+
aria-label={ariaLabel}
|
|
78
|
+
className={classNames}
|
|
79
|
+
data-eth-component="RecentItems"
|
|
80
|
+
>
|
|
81
|
+
<ul>
|
|
82
|
+
{items.map((item) => {
|
|
83
|
+
const kindLabel = formatRecentKind(item.kind);
|
|
84
|
+
const rowLabel = `Open ${item.label}, ${kindLabel}, last visited ${item.visitedAt}`;
|
|
85
|
+
const content = (
|
|
86
|
+
<>
|
|
87
|
+
<span className="eth-search-recent-items__icon" aria-hidden="true">
|
|
88
|
+
<RecentItemIcon kind={item.kind} />
|
|
89
|
+
</span>
|
|
90
|
+
<span className="eth-search-recent-items__copy">
|
|
91
|
+
<span className="eth-search-recent-items__label">{item.label}</span>
|
|
92
|
+
{item.description ? (
|
|
93
|
+
<span className="eth-search-recent-items__description">{item.description}</span>
|
|
94
|
+
) : null}
|
|
95
|
+
</span>
|
|
96
|
+
<span className="eth-search-recent-items__kind">{kindLabel}</span>
|
|
97
|
+
<span className="eth-search-recent-items__visited">
|
|
98
|
+
<span className="eth-search-recent-items__visited-label">Last visited</span>
|
|
99
|
+
<time dateTime={item.visitedAt}>{item.visitedAt}</time>
|
|
100
|
+
</span>
|
|
101
|
+
<ChevronRightIcon
|
|
102
|
+
className="eth-search-recent-items__arrow"
|
|
103
|
+
size={16}
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
/>
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<li key={item.id}>
|
|
111
|
+
{item.href ? (
|
|
112
|
+
<a
|
|
113
|
+
className="eth-search-recent-items__row"
|
|
114
|
+
href={item.href}
|
|
115
|
+
aria-label={rowLabel}
|
|
116
|
+
onClick={() => onSelect?.(item.id)}
|
|
117
|
+
>
|
|
118
|
+
{content}
|
|
119
|
+
</a>
|
|
120
|
+
) : (
|
|
121
|
+
<button
|
|
122
|
+
className="eth-search-recent-items__row"
|
|
123
|
+
type="button"
|
|
124
|
+
aria-label={rowLabel}
|
|
125
|
+
onClick={() => onSelect?.(item.id)}
|
|
126
|
+
>
|
|
127
|
+
{content}
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
</li>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</ul>
|
|
134
|
+
</section>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { EntitySearchInput } from "./EntitySearchInput";
|
|
2
|
+
import type { ResourceSearchProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function ResourceSearch({
|
|
5
|
+
resourceScope,
|
|
6
|
+
placeholder = "Search resources",
|
|
7
|
+
emptyText = "No matching resources",
|
|
8
|
+
suggestionsLabel = "Resource suggestions",
|
|
9
|
+
resultsLabel = "Resource matches",
|
|
10
|
+
loadingLabel = "Searching resources",
|
|
11
|
+
className,
|
|
12
|
+
...props
|
|
13
|
+
}: ResourceSearchProps) {
|
|
14
|
+
return (
|
|
15
|
+
<EntitySearchInput
|
|
16
|
+
{...props}
|
|
17
|
+
placeholder={placeholder}
|
|
18
|
+
emptyText={emptyText}
|
|
19
|
+
suggestionsLabel={suggestionsLabel}
|
|
20
|
+
resultsLabel={resultsLabel}
|
|
21
|
+
loadingLabel={loadingLabel}
|
|
22
|
+
className={["eth-search-resource", className].filter(Boolean).join(" ")}
|
|
23
|
+
data-resource-scope={resourceScope}
|
|
24
|
+
data-eth-component="ResourceSearch"
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button, IconButton } from "@echothink-ui/core";
|
|
3
|
+
import type { SavedSearchesProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export function SavedSearches({ searches, onRun, onDelete, className, ...props }: SavedSearchesProps) {
|
|
6
|
+
return (
|
|
7
|
+
<section
|
|
8
|
+
{...props}
|
|
9
|
+
className={`eth-search-saved-searches ${className ?? ""}`}
|
|
10
|
+
data-eth-component="SavedSearches"
|
|
11
|
+
>
|
|
12
|
+
<ul>
|
|
13
|
+
{searches.map((search) => (
|
|
14
|
+
<li key={search.id}>
|
|
15
|
+
<div>
|
|
16
|
+
<strong>{search.label}</strong>
|
|
17
|
+
<code>{search.query}</code>
|
|
18
|
+
<small>{search.updatedAt}</small>
|
|
19
|
+
</div>
|
|
20
|
+
{onRun ? <Button intent="secondary" density="compact" onClick={() => onRun(search.id)}>Run</Button> : null}
|
|
21
|
+
{onDelete ? <IconButton label="Delete saved search" intent="ghost" icon={<span aria-hidden>X</span>} onClick={() => onDelete(search.id)} /> : null}
|
|
22
|
+
</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
</section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { SearchFacetPanelProps } from "./types";
|
|
2
|
+
|
|
3
|
+
export function SearchFacetPanel({
|
|
4
|
+
facets,
|
|
5
|
+
selected,
|
|
6
|
+
onChange,
|
|
7
|
+
className,
|
|
8
|
+
"aria-label": ariaLabel,
|
|
9
|
+
...props
|
|
10
|
+
}: SearchFacetPanelProps) {
|
|
11
|
+
const selectedTotal = facets.reduce(
|
|
12
|
+
(total, facet) => total + (selected[facet.id]?.length ?? 0),
|
|
13
|
+
0
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const toggle = (facetId: string, value: string, checked: boolean) => {
|
|
17
|
+
const current = selected[facetId] ?? [];
|
|
18
|
+
|
|
19
|
+
onChange({
|
|
20
|
+
...selected,
|
|
21
|
+
[facetId]: checked ? [...current, value] : current.filter((item) => item !== value)
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const clearAll = () => {
|
|
26
|
+
onChange(Object.fromEntries(facets.map((facet) => [facet.id, []])));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<aside
|
|
31
|
+
className={["eth-search-facet-panel", className].filter(Boolean).join(" ")}
|
|
32
|
+
data-eth-component="SearchFacetPanel"
|
|
33
|
+
aria-label={ariaLabel ?? "Search filters"}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
<div className="eth-search-facet-panel__header">
|
|
37
|
+
<div>
|
|
38
|
+
<h2 className="eth-search-facet-panel__title">Filters</h2>
|
|
39
|
+
<p className="eth-search-facet-panel__summary">
|
|
40
|
+
{selectedTotal > 0 ? `${selectedTotal} selected` : "Refine results"}
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
{selectedTotal > 0 ? (
|
|
44
|
+
<button type="button" className="eth-search-facet-panel__clear" onClick={clearAll}>
|
|
45
|
+
Clear
|
|
46
|
+
</button>
|
|
47
|
+
) : null}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{facets.length > 0 ? (
|
|
51
|
+
<div className="eth-search-facet-panel__groups">
|
|
52
|
+
{facets.map((facet) => (
|
|
53
|
+
<fieldset key={facet.id} className="eth-search-facet-panel__group">
|
|
54
|
+
<legend className="eth-search-facet-panel__legend">
|
|
55
|
+
<span>{facet.label}</span>
|
|
56
|
+
</legend>
|
|
57
|
+
<div className="eth-search-facet-panel__options">
|
|
58
|
+
{facet.options.map((option) => {
|
|
59
|
+
const checked = (selected[facet.id] ?? []).includes(option.value);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<label
|
|
63
|
+
key={option.value}
|
|
64
|
+
className={[
|
|
65
|
+
"eth-search-facet-panel__option",
|
|
66
|
+
checked ? "eth-search-facet-panel__option--checked" : "",
|
|
67
|
+
option.disabled ? "eth-search-facet-panel__option--disabled" : ""
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join(" ")}
|
|
71
|
+
>
|
|
72
|
+
<input
|
|
73
|
+
type="checkbox"
|
|
74
|
+
checked={checked}
|
|
75
|
+
disabled={option.disabled}
|
|
76
|
+
value={option.value}
|
|
77
|
+
onChange={(event) =>
|
|
78
|
+
toggle(facet.id, option.value, event.currentTarget.checked)
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
<span className="eth-search-facet-panel__control" aria-hidden="true" />
|
|
82
|
+
<span className="eth-search-facet-panel__option-main">
|
|
83
|
+
<span className="eth-search-facet-panel__option-label">{option.label}</span>
|
|
84
|
+
{typeof option.count === "number" ? (
|
|
85
|
+
<span className="eth-search-facet-panel__option-count">{option.count}</span>
|
|
86
|
+
) : null}
|
|
87
|
+
</span>
|
|
88
|
+
</label>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
</fieldset>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
) : (
|
|
96
|
+
<p className="eth-search-facet-panel__empty">No filters available</p>
|
|
97
|
+
)}
|
|
98
|
+
</aside>
|
|
99
|
+
);
|
|
100
|
+
}
|