@semiont/react-ui 0.4.17 → 0.4.18
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/dist/index.d.mts +44 -15
- package/dist/index.mjs +10295 -353
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/modals/ResourceSearchModal.tsx +69 -57
- package/src/components/modals/SearchModal.tsx +54 -51
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +59 -82
- package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +221 -0
- package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +46 -45
- package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +23 -29
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchModal — wiring + UI tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies that SearchModal correctly wires createSearchPipeline to
|
|
5
|
+
* browse.resources, maps results to its SearchResult shape, and renders
|
|
6
|
+
* each emission. Pure pipeline behavior (debounce, distinct, switchMap,
|
|
7
|
+
* loading state) is covered by search-pipeline.test.ts and not duplicated
|
|
8
|
+
* here.
|
|
9
|
+
*
|
|
10
|
+
* Mocks HeadlessUI to dodge the jsdom OOM that prevents the older
|
|
11
|
+
* SearchModal.* test files from running.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
17
|
+
import { BehaviorSubject } from 'rxjs';
|
|
18
|
+
import { renderWithProviders } from '../../../test-utils';
|
|
19
|
+
import '@testing-library/jest-dom';
|
|
20
|
+
import { SearchModal } from '../SearchModal';
|
|
21
|
+
|
|
22
|
+
// Mock HeadlessUI to avoid jsdom OOM issues
|
|
23
|
+
vi.mock('@headlessui/react', () => ({
|
|
24
|
+
Dialog: ({ children, ...props }: any) => (
|
|
25
|
+
<div role="dialog" {...props}>
|
|
26
|
+
{typeof children === 'function' ? children({ open: true }) : children}
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
DialogPanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
30
|
+
DialogTitle: ({ children, ...props }: any) => <h2 {...props}>{children}</h2>,
|
|
31
|
+
Transition: ({ show, children }: any) => (show ? <>{children}</> : null),
|
|
32
|
+
TransitionChild: ({ children }: any) => <>{children}</>,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Mock the api-client Observable surface
|
|
36
|
+
const browseResourcesSubject = new BehaviorSubject<any[] | undefined>(undefined);
|
|
37
|
+
const browseResourcesMock = vi.fn(() => browseResourcesSubject.asObservable());
|
|
38
|
+
|
|
39
|
+
// Stable client reference — useApiClient is called on every render, so a
|
|
40
|
+
// fresh object literal would invalidate useMemo deps and restart the RxJS
|
|
41
|
+
// pipeline on every keystroke. The real ApiClientProvider holds a single
|
|
42
|
+
// instance; the mock must do the same.
|
|
43
|
+
const stableMockClient = { browse: { resources: browseResourcesMock } };
|
|
44
|
+
|
|
45
|
+
vi.mock('../../../contexts/ApiClientContext', async () => {
|
|
46
|
+
const actual = await vi.importActual<typeof import('../../../contexts/ApiClientContext')>(
|
|
47
|
+
'../../../contexts/ApiClientContext'
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
...actual,
|
|
51
|
+
useApiClient: () => stableMockClient,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Mock search announcements
|
|
56
|
+
vi.mock('../../../hooks/useSearchAnnouncements', () => ({
|
|
57
|
+
useSearchAnnouncements: vi.fn(() => ({
|
|
58
|
+
announceSearchResults: vi.fn(),
|
|
59
|
+
announceSearching: vi.fn(),
|
|
60
|
+
announceNavigation: vi.fn(),
|
|
61
|
+
})),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
function setBrowseResults(resources: any[] | undefined) {
|
|
65
|
+
act(() => {
|
|
66
|
+
browseResourcesSubject.next(resources);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const buildResource = (id: string, name: string, content?: string) => ({
|
|
71
|
+
'@context': 'https://www.w3.org/ns/anno.jsonld',
|
|
72
|
+
'@id': id,
|
|
73
|
+
name,
|
|
74
|
+
content,
|
|
75
|
+
representations: [{ mediaType: 'text/plain', isPrimary: true }],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('SearchModal — search wiring', () => {
|
|
79
|
+
const defaultProps = {
|
|
80
|
+
isOpen: true,
|
|
81
|
+
onClose: vi.fn(),
|
|
82
|
+
onNavigate: vi.fn(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
browseResourcesSubject.next(undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders the search input when open', () => {
|
|
91
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
92
|
+
expect(screen.getByPlaceholderText('Search resources, entities...')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not render when closed', () => {
|
|
96
|
+
renderWithProviders(<SearchModal {...defaultProps} isOpen={false} />);
|
|
97
|
+
expect(screen.queryByPlaceholderText('Search resources, entities...')).not.toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('shows the start-typing hint when query is empty', () => {
|
|
101
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
102
|
+
expect(screen.getByText('Start typing to search...')).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('wires the modal to browse.resources with the correct shape', async () => {
|
|
106
|
+
// One integration check that the modal calls browse.resources with the
|
|
107
|
+
// limit and search shape it advertises. Pipeline mechanics (debounce,
|
|
108
|
+
// empty-query gating) live in search-pipeline.test.ts.
|
|
109
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
110
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
111
|
+
|
|
112
|
+
fireEvent.change(input, { target: { value: 'marathon' } });
|
|
113
|
+
|
|
114
|
+
await waitFor(
|
|
115
|
+
() => expect(browseResourcesMock).toHaveBeenCalledWith({ search: 'marathon', limit: 5 }),
|
|
116
|
+
{ timeout: 1500 }
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders results when the Observable emits a non-empty list', async () => {
|
|
121
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
122
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
123
|
+
fireEvent.change(input, { target: { value: 'test' } });
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
|
|
126
|
+
setBrowseResults([
|
|
127
|
+
buildResource('res-1', 'Test Document', 'Some content here'),
|
|
128
|
+
buildResource('res-2', 'Another Result', 'More content'),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(screen.getByText('Test Document')).toBeInTheDocument();
|
|
133
|
+
expect(screen.getByText('Another Result')).toBeInTheDocument();
|
|
134
|
+
}, { timeout: 1500 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shows the no-results message after an empty emission', async () => {
|
|
138
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
139
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
140
|
+
fireEvent.change(input, { target: { value: 'xyz' } });
|
|
141
|
+
|
|
142
|
+
await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
|
|
143
|
+
setBrowseResults([]);
|
|
144
|
+
|
|
145
|
+
await waitFor(() => {
|
|
146
|
+
expect(screen.getByText(/No results found for/)).toBeInTheDocument();
|
|
147
|
+
expect(screen.getByText(/"xyz"/)).toBeInTheDocument();
|
|
148
|
+
}, { timeout: 1500 });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('shows the searching state while the Observable has not emitted', async () => {
|
|
152
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
153
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
154
|
+
fireEvent.change(input, { target: { value: 'foo' } });
|
|
155
|
+
|
|
156
|
+
// After debounce fires, the inner observable starts with { results: [], loading: true }.
|
|
157
|
+
await waitFor(() => expect(screen.getByText('Searching...')).toBeInTheDocument(), {
|
|
158
|
+
timeout: 1500,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('navigates to a result and closes when clicked', async () => {
|
|
163
|
+
const onClose = vi.fn();
|
|
164
|
+
const onNavigate = vi.fn();
|
|
165
|
+
renderWithProviders(
|
|
166
|
+
<SearchModal {...defaultProps} onClose={onClose} onNavigate={onNavigate} />
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
170
|
+
fireEvent.change(input, { target: { value: 'doc' } });
|
|
171
|
+
|
|
172
|
+
await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
|
|
173
|
+
setBrowseResults([buildResource('res-1', 'Pickable', 'preview')]);
|
|
174
|
+
|
|
175
|
+
await waitFor(() => expect(screen.getByText('Pickable')).toBeInTheDocument(), {
|
|
176
|
+
timeout: 1500,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
fireEvent.click(screen.getByText('Pickable'));
|
|
180
|
+
expect(onNavigate).toHaveBeenCalledWith('resource', 'res-1');
|
|
181
|
+
expect(onClose).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('moves selection with arrow keys and selects with Enter', async () => {
|
|
185
|
+
const onNavigate = vi.fn();
|
|
186
|
+
renderWithProviders(<SearchModal {...defaultProps} onNavigate={onNavigate} />);
|
|
187
|
+
|
|
188
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
189
|
+
fireEvent.change(input, { target: { value: 'doc' } });
|
|
190
|
+
|
|
191
|
+
await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
|
|
192
|
+
setBrowseResults([
|
|
193
|
+
buildResource('res-1', 'First'),
|
|
194
|
+
buildResource('res-2', 'Second'),
|
|
195
|
+
buildResource('res-3', 'Third'),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
await waitFor(() => expect(screen.getByText('Third')).toBeInTheDocument(), { timeout: 1500 });
|
|
199
|
+
|
|
200
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
201
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
202
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
203
|
+
|
|
204
|
+
expect(onNavigate).toHaveBeenCalledWith('resource', 'res-3');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('skips resources without an @id', async () => {
|
|
208
|
+
renderWithProviders(<SearchModal {...defaultProps} />);
|
|
209
|
+
const input = screen.getByPlaceholderText('Search resources, entities...');
|
|
210
|
+
fireEvent.change(input, { target: { value: 'q' } });
|
|
211
|
+
|
|
212
|
+
await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
|
|
213
|
+
setBrowseResults([
|
|
214
|
+
{ '@context': 'https://www.w3.org/ns/anno.jsonld', name: 'No ID', representations: [] },
|
|
215
|
+
buildResource('res-keep', 'Has ID'),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
await waitFor(() => expect(screen.getByText('Has ID')).toBeInTheDocument(), { timeout: 1500 });
|
|
219
|
+
expect(screen.queryByText('No ID')).not.toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
9
|
-
import { render, screen, fireEvent
|
|
9
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
10
10
|
import { ResourceDiscoveryPage } from '../components/ResourceDiscoveryPage';
|
|
11
11
|
import type { ResourceDiscoveryPageProps } from '../components/ResourceDiscoveryPage';
|
|
12
12
|
import { EventBusProvider } from '../../../contexts/EventBusContext';
|
|
@@ -29,6 +29,8 @@ const createMockProps = (overrides?: Partial<ResourceDiscoveryPageProps>): Resou
|
|
|
29
29
|
entityTypes: [],
|
|
30
30
|
isLoadingRecent: false,
|
|
31
31
|
isSearching: false,
|
|
32
|
+
searchQuery: '',
|
|
33
|
+
onSearchQueryChange: vi.fn(),
|
|
32
34
|
theme: 'light',
|
|
33
35
|
showLineNumbers: false,
|
|
34
36
|
activePanel: null,
|
|
@@ -88,13 +90,6 @@ describe('ResourceDiscoveryPage', () => {
|
|
|
88
90
|
expect(screen.getByPlaceholderText('Search resources...')).toBeInTheDocument();
|
|
89
91
|
});
|
|
90
92
|
|
|
91
|
-
it('renders search button', () => {
|
|
92
|
-
const props = createMockProps();
|
|
93
|
-
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
94
|
-
|
|
95
|
-
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
93
|
it('renders toolbar component', () => {
|
|
99
94
|
const props = createMockProps();
|
|
100
95
|
const { container } = renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
@@ -172,69 +167,75 @@ describe('ResourceDiscoveryPage', () => {
|
|
|
172
167
|
});
|
|
173
168
|
|
|
174
169
|
describe('Search Functionality', () => {
|
|
175
|
-
it('
|
|
176
|
-
const props = createMockProps();
|
|
170
|
+
it('reflects controlled searchQuery prop in the input', () => {
|
|
171
|
+
const props = createMockProps({ searchQuery: 'hello' });
|
|
177
172
|
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
178
173
|
|
|
179
174
|
const input = screen.getByPlaceholderText('Search resources...') as HTMLInputElement;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
expect(input.value).toBe('test query');
|
|
175
|
+
expect(input.value).toBe('hello');
|
|
183
176
|
});
|
|
184
177
|
|
|
185
|
-
it('
|
|
186
|
-
const
|
|
178
|
+
it('calls onSearchQueryChange on every keystroke', () => {
|
|
179
|
+
const onSearchQueryChange = vi.fn();
|
|
180
|
+
const props = createMockProps({ onSearchQueryChange });
|
|
187
181
|
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
188
182
|
|
|
189
|
-
|
|
183
|
+
const input = screen.getByPlaceholderText('Search resources...');
|
|
184
|
+
fireEvent.change(input, { target: { value: 'a' } });
|
|
185
|
+
fireEvent.change(input, { target: { value: 'ab' } });
|
|
186
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
187
|
+
|
|
188
|
+
expect(onSearchQueryChange).toHaveBeenCalledTimes(3);
|
|
189
|
+
expect(onSearchQueryChange).toHaveBeenNthCalledWith(1, 'a');
|
|
190
|
+
expect(onSearchQueryChange).toHaveBeenNthCalledWith(2, 'ab');
|
|
191
|
+
expect(onSearchQueryChange).toHaveBeenNthCalledWith(3, 'abc');
|
|
190
192
|
});
|
|
191
193
|
|
|
192
|
-
it('
|
|
193
|
-
const props = createMockProps({ isSearching: true });
|
|
194
|
+
it('shows the searching indicator when isSearching is true', () => {
|
|
195
|
+
const props = createMockProps({ isSearching: true, searchQuery: 'foo' });
|
|
194
196
|
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
195
197
|
|
|
196
|
-
|
|
197
|
-
expect(input).toBeDisabled();
|
|
198
|
+
expect(screen.getByText('Searching...')).toBeInTheDocument();
|
|
198
199
|
});
|
|
199
200
|
|
|
200
|
-
it('
|
|
201
|
-
const props = createMockProps({
|
|
201
|
+
it('renders searchDocuments when searchQuery is non-empty', () => {
|
|
202
|
+
const props = createMockProps({
|
|
203
|
+
searchQuery: 'res',
|
|
204
|
+
searchDocuments: [
|
|
205
|
+
createMockResource('1', 'Search Result 1'),
|
|
206
|
+
createMockResource('2', 'Search Result 2'),
|
|
207
|
+
],
|
|
208
|
+
recentDocuments: [createMockResource('99', 'Recent Doc')],
|
|
209
|
+
});
|
|
202
210
|
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
203
211
|
|
|
204
|
-
|
|
205
|
-
expect(
|
|
212
|
+
expect(screen.getByText('Search Result 1')).toBeInTheDocument();
|
|
213
|
+
expect(screen.getByText('Search Result 2')).toBeInTheDocument();
|
|
214
|
+
expect(screen.queryByText('Recent Doc')).not.toBeInTheDocument();
|
|
215
|
+
expect(screen.getByText('2 results found')).toBeInTheDocument();
|
|
206
216
|
});
|
|
207
217
|
|
|
208
|
-
it('
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
218
|
+
it('shows no-results warning when searchQuery is non-empty, results empty, and not searching', () => {
|
|
219
|
+
const props = createMockProps({
|
|
220
|
+
searchQuery: 'nonexistent',
|
|
221
|
+
searchDocuments: [],
|
|
222
|
+
isSearching: false,
|
|
223
|
+
recentDocuments: [createMockResource('1', 'Recent Doc')],
|
|
224
|
+
});
|
|
215
225
|
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
216
226
|
|
|
217
|
-
|
|
218
|
-
const input = screen.getByPlaceholderText('Search resources...');
|
|
219
|
-
fireEvent.change(input, { target: { value: 'test' } });
|
|
220
|
-
|
|
221
|
-
expect(screen.getByText('Result 1')).toBeInTheDocument();
|
|
222
|
-
expect(screen.getByText('Result 2')).toBeInTheDocument();
|
|
227
|
+
expect(screen.getByText('No results found for "nonexistent"')).toBeInTheDocument();
|
|
223
228
|
});
|
|
224
229
|
|
|
225
|
-
it('
|
|
230
|
+
it('does not show no-results warning while still searching', () => {
|
|
226
231
|
const props = createMockProps({
|
|
232
|
+
searchQuery: 'foo',
|
|
227
233
|
searchDocuments: [],
|
|
228
|
-
|
|
234
|
+
isSearching: true,
|
|
229
235
|
});
|
|
230
236
|
renderWithProviders(<ResourceDiscoveryPage {...props} />);
|
|
231
237
|
|
|
232
|
-
|
|
233
|
-
fireEvent.change(input, { target: { value: 'nonexistent' } });
|
|
234
|
-
|
|
235
|
-
await waitFor(() => {
|
|
236
|
-
expect(screen.getByText('No results found for "nonexistent"')).toBeInTheDocument();
|
|
237
|
-
});
|
|
238
|
+
expect(screen.queryByText(/No results found/)).not.toBeInTheDocument();
|
|
238
239
|
});
|
|
239
240
|
});
|
|
240
241
|
|
|
@@ -23,6 +23,10 @@ export interface ResourceDiscoveryPageProps {
|
|
|
23
23
|
isLoadingRecent: boolean;
|
|
24
24
|
isSearching: boolean;
|
|
25
25
|
|
|
26
|
+
// Controlled search state
|
|
27
|
+
searchQuery: string;
|
|
28
|
+
onSearchQueryChange: (query: string) => void;
|
|
29
|
+
|
|
26
30
|
// UI state props
|
|
27
31
|
theme: 'light' | 'dark';
|
|
28
32
|
showLineNumbers: boolean;
|
|
@@ -62,6 +66,8 @@ export function ResourceDiscoveryPage({
|
|
|
62
66
|
entityTypes,
|
|
63
67
|
isLoadingRecent,
|
|
64
68
|
isSearching,
|
|
69
|
+
searchQuery,
|
|
70
|
+
onSearchQueryChange,
|
|
65
71
|
theme,
|
|
66
72
|
showLineNumbers,
|
|
67
73
|
activePanel,
|
|
@@ -70,15 +76,12 @@ export function ResourceDiscoveryPage({
|
|
|
70
76
|
translations: t,
|
|
71
77
|
ToolbarPanels,
|
|
72
78
|
}: ResourceDiscoveryPageProps) {
|
|
73
|
-
// Search and filter state
|
|
74
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
75
79
|
const [selectedEntityType, setSelectedEntityType] = useState<string>('');
|
|
76
80
|
|
|
77
81
|
const hasSearchQuery = searchQuery.trim() !== '';
|
|
78
|
-
const hasSearchResults = searchDocuments.length > 0;
|
|
79
82
|
|
|
80
|
-
//
|
|
81
|
-
const baseDocuments =
|
|
83
|
+
// When searching, render search results; otherwise render recent.
|
|
84
|
+
const baseDocuments = hasSearchQuery ? searchDocuments : recentDocuments;
|
|
82
85
|
const filteredResources = !selectedEntityType
|
|
83
86
|
? baseDocuments
|
|
84
87
|
: baseDocuments.filter((resource: ResourceDescriptor) =>
|
|
@@ -113,11 +116,6 @@ export function ResourceDiscoveryPage({
|
|
|
113
116
|
}
|
|
114
117
|
}, []);
|
|
115
118
|
|
|
116
|
-
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
|
|
117
|
-
e.preventDefault();
|
|
118
|
-
// Search is handled by debounced effect
|
|
119
|
-
}, []);
|
|
120
|
-
|
|
121
119
|
// Loading state
|
|
122
120
|
if (isLoadingRecent) {
|
|
123
121
|
return (
|
|
@@ -127,7 +125,7 @@ export function ResourceDiscoveryPage({
|
|
|
127
125
|
);
|
|
128
126
|
}
|
|
129
127
|
|
|
130
|
-
const showNoResultsWarning = hasSearchQuery &&
|
|
128
|
+
const showNoResultsWarning = hasSearchQuery && searchDocuments.length === 0 && !isSearching;
|
|
131
129
|
|
|
132
130
|
return (
|
|
133
131
|
<div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
|
|
@@ -144,25 +142,23 @@ export function ResourceDiscoveryPage({
|
|
|
144
142
|
{/* Search and Filter Section */}
|
|
145
143
|
<div className="semiont-card">
|
|
146
144
|
{/* Search Bar */}
|
|
147
|
-
<
|
|
145
|
+
<div className="semiont-card__search-form">
|
|
148
146
|
<div className="semiont-card__search-wrapper">
|
|
149
147
|
<input
|
|
150
148
|
type="text"
|
|
151
149
|
value={searchQuery}
|
|
152
|
-
onChange={(e) =>
|
|
150
|
+
onChange={(e) => onSearchQueryChange(e.target.value)}
|
|
153
151
|
placeholder={t.searchPlaceholder}
|
|
154
152
|
className="semiont-card__search-input"
|
|
155
|
-
|
|
153
|
+
aria-label={t.searchPlaceholder}
|
|
156
154
|
/>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{isSearching ? t.searching : t.searchButton}
|
|
163
|
-
</button>
|
|
155
|
+
{isSearching && (
|
|
156
|
+
<span className="semiont-card__search-status" aria-live="polite">
|
|
157
|
+
{t.searching}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
164
160
|
</div>
|
|
165
|
-
</
|
|
161
|
+
</div>
|
|
166
162
|
|
|
167
163
|
{/* Entity Type Filters */}
|
|
168
164
|
{entityTypes.length > 0 && (
|
|
@@ -205,13 +201,11 @@ export function ResourceDiscoveryPage({
|
|
|
205
201
|
{/* Documents Grid */}
|
|
206
202
|
<div className="semiont-card__documents">
|
|
207
203
|
<h3 className="semiont-card__documents-label">
|
|
208
|
-
{
|
|
209
|
-
? t.
|
|
210
|
-
:
|
|
211
|
-
? t.
|
|
212
|
-
:
|
|
213
|
-
? t.documentsTaggedWith(selectedEntityType)
|
|
214
|
-
: t.recentResources
|
|
204
|
+
{hasSearchQuery && searchDocuments.length > 0
|
|
205
|
+
? t.searchResults(searchDocuments.length)
|
|
206
|
+
: selectedEntityType
|
|
207
|
+
? t.documentsTaggedWith(selectedEntityType)
|
|
208
|
+
: t.recentResources
|
|
215
209
|
}
|
|
216
210
|
</h3>
|
|
217
211
|
|