@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,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
+ }