@jasonshimmy/vite-plugin-cer-app 0.22.0 → 0.23.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/CHANGELOG.md +4 -0
- package/commits.txt +1 -1
- package/dist/runtime/composables/use-content-search.d.ts +8 -3
- package/dist/runtime/composables/use-content-search.d.ts.map +1 -1
- package/dist/runtime/composables/use-content-search.js +41 -18
- package/dist/runtime/composables/use-content-search.js.map +1 -1
- package/docs/composables.md +5 -3
- package/e2e/cypress/e2e/content.cy.ts +23 -3
- package/e2e/kitchen-sink/app/pages/content-search.ts +4 -3
- package/package.json +1 -1
- package/src/__tests__/runtime/use-content-search.test.ts +4 -2
- package/src/runtime/composables/use-content-search.ts +40 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [v0.23.0] - 2026-04-17
|
|
5
|
+
|
|
6
|
+
- feat: enhance useContentSearch with loading state and update related tests (d1cee0e)
|
|
7
|
+
|
|
4
8
|
## [v0.22.0] - 2026-04-16
|
|
5
9
|
|
|
6
10
|
- feat: add customColors support for jitCss configuration and implement related tests (b8061ef)
|
package/commits.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
- feat:
|
|
1
|
+
- feat: enhance useContentSearch with loading state and update related tests (d1cee0e)
|
|
@@ -13,6 +13,8 @@ export declare function resetIndexSingleton(): void;
|
|
|
13
13
|
export interface UseContentSearchReturn {
|
|
14
14
|
query: ReactiveState<string>;
|
|
15
15
|
results: ReactiveState<ContentSearchResult[]>;
|
|
16
|
+
/** `true` from the moment the user starts typing until results (or an error) arrive. `false` when the query is empty or the search is complete. */
|
|
17
|
+
loading: ReactiveState<boolean>;
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* Full-text content search composable.
|
|
@@ -21,8 +23,10 @@ export interface UseContentSearchReturn {
|
|
|
21
23
|
* `/_content/search-index.json`. Both MiniSearch and the index are loaded via
|
|
22
24
|
* dynamic import — neither is in the app bundle.
|
|
23
25
|
*
|
|
24
|
-
* Searches `title` and `description` fields.
|
|
25
|
-
*
|
|
26
|
+
* Searches `title` and `description` fields. Input is debounced (200 ms) so
|
|
27
|
+
* the index is not queried on every keystroke. `loading` becomes `true` as soon
|
|
28
|
+
* as the user starts typing and returns to `false` once results arrive. Results
|
|
29
|
+
* are empty when the query is empty.
|
|
26
30
|
*
|
|
27
31
|
* **SSR note**: search is always client-side. In SSR mode the component renders
|
|
28
32
|
* with empty results and hydrates on mount.
|
|
@@ -30,10 +34,11 @@ export interface UseContentSearchReturn {
|
|
|
30
34
|
* @example
|
|
31
35
|
* ```ts
|
|
32
36
|
* component('site-search', () => {
|
|
33
|
-
* const { query, results } = useContentSearch()
|
|
37
|
+
* const { query, results, loading } = useContentSearch()
|
|
34
38
|
*
|
|
35
39
|
* return html`
|
|
36
40
|
* <input type="search" :model="${query}" placeholder="Search…" />
|
|
41
|
+
* ${loading.value ? html`<p>Searching…</p>` : ''}
|
|
37
42
|
* ${when(results.value.length > 0, () => html`
|
|
38
43
|
* <ul>
|
|
39
44
|
* ${each(results.value, r => html`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-content-search.d.ts","sourceRoot":"","sources":["../../../src/runtime/composables/use-content-search.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sCAAsC,CAAA;AACzE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAA;AASjE;;;;;;GAMG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC,CAiBlD;AAED,uEAAuE;AACvE,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAID,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC5B,OAAO,EAAE,aAAa,CAAC,mBAAmB,EAAE,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"use-content-search.d.ts","sourceRoot":"","sources":["../../../src/runtime/composables/use-content-search.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sCAAsC,CAAA;AACzE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAA;AASjE;;;;;;GAMG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC,CAiBlD;AAED,uEAAuE;AACvE,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAID,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC5B,OAAO,EAAE,aAAa,CAAC,mBAAmB,EAAE,CAAC,CAAA;IAC7C,mJAAmJ;IACnJ,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAChC;AAuDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,gBAAgB,IAAI,sBAAsB,CAEzD"}
|
|
@@ -38,31 +38,51 @@ export function resetIndexSingleton() {
|
|
|
38
38
|
const _factory = createComposable(() => {
|
|
39
39
|
const query = ref('');
|
|
40
40
|
const results = ref([]);
|
|
41
|
+
const loading = ref(false);
|
|
41
42
|
// Pre-warm index on mount
|
|
42
43
|
useOnConnected(() => {
|
|
43
44
|
loadIndex().catch(() => { });
|
|
44
45
|
});
|
|
45
46
|
// Monotonic counter to discard stale async results
|
|
46
47
|
let _seq = 0;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
let _debounceTimer = null;
|
|
49
|
+
watch(query, (q) => {
|
|
50
|
+
if (_debounceTimer !== null) {
|
|
51
|
+
clearTimeout(_debounceTimer);
|
|
52
|
+
_debounceTimer = null;
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
results.value = index.search(q, { prefix: true });
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
if (seq !== _seq)
|
|
61
|
-
return;
|
|
54
|
+
if (!q) {
|
|
55
|
+
// Increment seq so any in-flight async search is discarded when it resolves
|
|
56
|
+
_seq++;
|
|
57
|
+
loading.value = false;
|
|
62
58
|
results.value = [];
|
|
59
|
+
return;
|
|
63
60
|
}
|
|
61
|
+
// Signal loading immediately so the UI can respond before the debounce fires
|
|
62
|
+
loading.value = true;
|
|
63
|
+
_debounceTimer = setTimeout(async () => {
|
|
64
|
+
_debounceTimer = null;
|
|
65
|
+
const seq = ++_seq;
|
|
66
|
+
try {
|
|
67
|
+
const index = await loadIndex();
|
|
68
|
+
if (seq !== _seq)
|
|
69
|
+
return; // stale — a newer query is in flight
|
|
70
|
+
results.value = index.search(q, { prefix: true });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
if (seq !== _seq)
|
|
74
|
+
return;
|
|
75
|
+
results.value = [];
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
// Only clear loading for the most recent search; a newer in-flight search
|
|
79
|
+
// keeps loading=true until it settles.
|
|
80
|
+
if (seq === _seq)
|
|
81
|
+
loading.value = false;
|
|
82
|
+
}
|
|
83
|
+
}, 200);
|
|
64
84
|
});
|
|
65
|
-
return { query, results };
|
|
85
|
+
return { query, results, loading };
|
|
66
86
|
});
|
|
67
87
|
/**
|
|
68
88
|
* Full-text content search composable.
|
|
@@ -71,8 +91,10 @@ const _factory = createComposable(() => {
|
|
|
71
91
|
* `/_content/search-index.json`. Both MiniSearch and the index are loaded via
|
|
72
92
|
* dynamic import — neither is in the app bundle.
|
|
73
93
|
*
|
|
74
|
-
* Searches `title` and `description` fields.
|
|
75
|
-
*
|
|
94
|
+
* Searches `title` and `description` fields. Input is debounced (200 ms) so
|
|
95
|
+
* the index is not queried on every keystroke. `loading` becomes `true` as soon
|
|
96
|
+
* as the user starts typing and returns to `false` once results arrive. Results
|
|
97
|
+
* are empty when the query is empty.
|
|
76
98
|
*
|
|
77
99
|
* **SSR note**: search is always client-side. In SSR mode the component renders
|
|
78
100
|
* with empty results and hydrates on mount.
|
|
@@ -80,10 +102,11 @@ const _factory = createComposable(() => {
|
|
|
80
102
|
* @example
|
|
81
103
|
* ```ts
|
|
82
104
|
* component('site-search', () => {
|
|
83
|
-
* const { query, results } = useContentSearch()
|
|
105
|
+
* const { query, results, loading } = useContentSearch()
|
|
84
106
|
*
|
|
85
107
|
* return html`
|
|
86
108
|
* <input type="search" :model="${query}" placeholder="Search…" />
|
|
109
|
+
* ${loading.value ? html`<p>Searching…</p>` : ''}
|
|
87
110
|
* ${when(results.value.length > 0, () => html`
|
|
88
111
|
* <ul>
|
|
89
112
|
* ${each(results.value, r => html`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-content-search.js","sourceRoot":"","sources":["../../../src/runtime/composables/use-content-search.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,GAAG,EACH,cAAc,EACd,KAAK,GACN,MAAM,sCAAsC,CAAA;AAG7C,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAA;AAE5D,iFAAiF;AAEjF,6EAA6E;AAC7E,iFAAiF;AACjF,IAAI,aAAa,GAA4B,IAAI,CAAA;AAEjD;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,IAAI,aAAa;QAAE,OAAO,aAAa,CAAA;IACvC,aAAa,GAAG,CAAC,KAAK,IAAI,EAAE;QAC1B,MAAM,CAAC,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,GAAG,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YACvD,MAAM,CAAC,YAAY,CAAC;YACpB,KAAK,CAAC,qBAAqB,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE;gBACxC,IAAI,CAAC,CAAC,CAAC,EAAE;oBAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;gBACvE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAA;YACjB,CAAC,CAAC;SACH,CAAC,CAAA;QACF,OAAO,UAAU,CAAC,QAAQ,CAAC,GAAG,EAAE;YAC9B,MAAM,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC;YAChC,WAAW,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC;YAC9C,OAAO,EAAE,OAAO;SACjB,CAAC,CAAA;IACJ,CAAC,CAAC,EAAE,CAAA;IACJ,OAAO,aAAa,CAAA;AACtB,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,mBAAmB;IACjC,aAAa,GAAG,IAAI,CAAA;AACtB,CAAC;
|
|
1
|
+
{"version":3,"file":"use-content-search.js","sourceRoot":"","sources":["../../../src/runtime/composables/use-content-search.ts"],"names":[],"mappings":"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"}
|
package/docs/composables.md
CHANGED
|
@@ -868,16 +868,17 @@ import { queryContent } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
|
868
868
|
|
|
869
869
|
### `useContentSearch()`
|
|
870
870
|
|
|
871
|
-
Reactive full-text search over the content layer. Loads the MiniSearch index lazily on first use. Returns `query` and `
|
|
871
|
+
Reactive full-text search over the content layer. Loads the MiniSearch index lazily on first use. Returns `query`, `results`, and `loading` refs that update reactively as the user types.
|
|
872
872
|
|
|
873
873
|
Requires `content: {}` in `cer.config.ts`. See [content.md](./content.md) for full documentation.
|
|
874
874
|
|
|
875
875
|
```ts
|
|
876
876
|
component('page-search', () => {
|
|
877
|
-
const { query, results } = useContentSearch()
|
|
877
|
+
const { query, results, loading } = useContentSearch()
|
|
878
878
|
|
|
879
879
|
return html`
|
|
880
880
|
<input type="search" :model="${query}" placeholder="Search…" />
|
|
881
|
+
${loading.value ? html`<p>Searching…</p>` : ''}
|
|
881
882
|
<ul>
|
|
882
883
|
${each(results.value, r => html`
|
|
883
884
|
<li><a :href="${r._path}">${r.title}</a></li>
|
|
@@ -893,10 +894,11 @@ component('page-search', () => {
|
|
|
893
894
|
interface UseContentSearchReturn {
|
|
894
895
|
query: Ref<string> // bind with :model
|
|
895
896
|
results: Ref<ContentSearchResult[]> // reactive search results
|
|
897
|
+
loading: Ref<boolean> // true while debounce is pending or search is in flight
|
|
896
898
|
}
|
|
897
899
|
```
|
|
898
900
|
|
|
899
|
-
Search
|
|
901
|
+
Search is debounced (200 ms) so the index is not queried on every keystroke. `loading` is set to `true` as soon as the user starts typing and returns to `false` once results arrive or the query is cleared. An empty query clears results immediately and cancels any in-flight search. MiniSearch is loaded once and cached for the lifetime of the page. Searched fields are `title` and `description`.
|
|
900
902
|
|
|
901
903
|
If you need it outside auto-imported directories:
|
|
902
904
|
|
|
@@ -240,14 +240,14 @@ describe('Content search — useContentSearch()', () => {
|
|
|
240
240
|
cy.get('[data-cy=content-search-input]').should('exist')
|
|
241
241
|
})
|
|
242
242
|
|
|
243
|
-
it('shows
|
|
243
|
+
it('shows results after typing a single character', () => {
|
|
244
244
|
cy.visit('/content-search')
|
|
245
245
|
cy.wait('@searchIndex')
|
|
246
246
|
setSearchQuery('H')
|
|
247
|
-
cy.get('[data-cy=content-search-result]').should('
|
|
247
|
+
cy.get('[data-cy=content-search-result]', { timeout: 8000 }).should('have.length.at.least', 1)
|
|
248
248
|
})
|
|
249
249
|
|
|
250
|
-
it('shows results after typing a
|
|
250
|
+
it('shows results after typing a multi-char query', () => {
|
|
251
251
|
cy.visit('/content-search')
|
|
252
252
|
cy.wait('@searchIndex')
|
|
253
253
|
setSearchQuery('He')
|
|
@@ -275,6 +275,26 @@ describe('Content search — useContentSearch()', () => {
|
|
|
275
275
|
cy.get('[data-cy=content-search-result]', { timeout: 8000 }).first().should('have.attr', 'data-path')
|
|
276
276
|
})
|
|
277
277
|
|
|
278
|
+
it('shows loading indicator while search is in flight', () => {
|
|
279
|
+
cy.visit('/content-search')
|
|
280
|
+
cy.wait('@searchIndex')
|
|
281
|
+
setSearchQuery('Hello')
|
|
282
|
+
// loading indicator appears immediately after typing
|
|
283
|
+
cy.get('[data-cy=content-search-loading]').should('exist')
|
|
284
|
+
// loading indicator disappears once results arrive
|
|
285
|
+
cy.get('[data-cy=content-search-loading]', { timeout: 8000 }).should('not.exist')
|
|
286
|
+
cy.get('[data-cy=content-search-result]').should('have.length.at.least', 1)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('clears loading indicator when query is cleared', () => {
|
|
290
|
+
cy.visit('/content-search')
|
|
291
|
+
cy.wait('@searchIndex')
|
|
292
|
+
setSearchQuery('Hello')
|
|
293
|
+
cy.get('[data-cy=content-search-loading]').should('exist')
|
|
294
|
+
setSearchQuery('')
|
|
295
|
+
cy.get('[data-cy=content-search-loading]').should('not.exist')
|
|
296
|
+
})
|
|
297
|
+
|
|
278
298
|
it('clearing query clears results', () => {
|
|
279
299
|
cy.visit('/content-search')
|
|
280
300
|
cy.wait('@searchIndex')
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Content search page — exercises `useContentSearch()`.
|
|
3
|
-
* Typing
|
|
3
|
+
* Typing into the search box triggers MiniSearch results (debounced).
|
|
4
4
|
* Route: /content-search
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
component('page-content-search', () => {
|
|
8
8
|
useHead({ title: 'Content Search — Kitchen Sink' })
|
|
9
9
|
|
|
10
|
-
const { query, results } = useContentSearch()
|
|
10
|
+
const { query, results, loading } = useContentSearch()
|
|
11
11
|
|
|
12
12
|
return html`
|
|
13
13
|
<div>
|
|
@@ -19,6 +19,7 @@ component('page-content-search', () => {
|
|
|
19
19
|
.value="${query.value}"
|
|
20
20
|
@input="${(e: Event) => { query.value = (e.target as HTMLInputElement).value }}"
|
|
21
21
|
/>
|
|
22
|
+
${loading.value ? html`<p data-cy="content-search-loading">Searching…</p>` : ''}
|
|
22
23
|
<ul data-cy="content-search-results">
|
|
23
24
|
${results.value.map(r => html`
|
|
24
25
|
<li data-cy="content-search-result" data-path="${r._path}">
|
|
@@ -27,7 +28,7 @@ component('page-content-search', () => {
|
|
|
27
28
|
</li>
|
|
28
29
|
`)}
|
|
29
30
|
</ul>
|
|
30
|
-
${results.value.length === 0 && query.value.length
|
|
31
|
+
${results.value.length === 0 && query.value.length > 0 && !loading.value ? html`
|
|
31
32
|
<p data-cy="content-search-empty">No results for "${query.value}".</p>
|
|
32
33
|
` : ''}
|
|
33
34
|
</div>
|
package/package.json
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - loadIndex() error path — fetch failure rejects cleanly; singleton reset allows retry
|
|
8
8
|
* - loadIndex() returns a searchable MiniSearch instance
|
|
9
9
|
*
|
|
10
|
-
* Note: The full useContentSearch() composable (
|
|
11
|
-
*
|
|
10
|
+
* Note: The full useContentSearch() composable (debounce, stale-seq guard,
|
|
11
|
+
* useOnConnected pre-warm) requires a component context provided by the
|
|
12
12
|
* custom-elements-runtime and is exercised by the e2e suite in content.cy.ts.
|
|
13
|
+
* Specifically: input is debounced (300 ms) and an empty query immediately
|
|
14
|
+
* clears results + increments the seq counter to cancel any in-flight search.
|
|
13
15
|
*/
|
|
14
16
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
15
17
|
import MiniSearch from 'minisearch'
|
|
@@ -50,11 +50,14 @@ export function resetIndexSingleton(): void {
|
|
|
50
50
|
export interface UseContentSearchReturn {
|
|
51
51
|
query: ReactiveState<string>
|
|
52
52
|
results: ReactiveState<ContentSearchResult[]>
|
|
53
|
+
/** `true` from the moment the user starts typing until results (or an error) arrive. `false` when the query is empty or the search is complete. */
|
|
54
|
+
loading: ReactiveState<boolean>
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const _factory = createComposable((): UseContentSearchReturn => {
|
|
56
58
|
const query = ref('')
|
|
57
59
|
const results = ref<ContentSearchResult[]>([])
|
|
60
|
+
const loading = ref(false)
|
|
58
61
|
|
|
59
62
|
// Pre-warm index on mount
|
|
60
63
|
useOnConnected(() => {
|
|
@@ -63,26 +66,45 @@ const _factory = createComposable((): UseContentSearchReturn => {
|
|
|
63
66
|
|
|
64
67
|
// Monotonic counter to discard stale async results
|
|
65
68
|
let _seq = 0
|
|
69
|
+
let _debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
66
70
|
|
|
67
|
-
watch(query,
|
|
68
|
-
|
|
71
|
+
watch(query, (q: string) => {
|
|
72
|
+
if (_debounceTimer !== null) {
|
|
73
|
+
clearTimeout(_debounceTimer)
|
|
74
|
+
_debounceTimer = null
|
|
75
|
+
}
|
|
69
76
|
|
|
70
|
-
if (!q
|
|
77
|
+
if (!q) {
|
|
78
|
+
// Increment seq so any in-flight async search is discarded when it resolves
|
|
79
|
+
_seq++
|
|
80
|
+
loading.value = false
|
|
71
81
|
results.value = []
|
|
72
82
|
return
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
// Signal loading immediately so the UI can respond before the debounce fires
|
|
86
|
+
loading.value = true
|
|
87
|
+
|
|
88
|
+
_debounceTimer = setTimeout(async () => {
|
|
89
|
+
_debounceTimer = null
|
|
90
|
+
const seq = ++_seq
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const index = await loadIndex() as { search(q: string, opts?: { prefix?: boolean }): ContentSearchResult[] }
|
|
94
|
+
if (seq !== _seq) return // stale — a newer query is in flight
|
|
95
|
+
results.value = index.search(q, { prefix: true }) as ContentSearchResult[]
|
|
96
|
+
} catch {
|
|
97
|
+
if (seq !== _seq) return
|
|
98
|
+
results.value = []
|
|
99
|
+
} finally {
|
|
100
|
+
// Only clear loading for the most recent search; a newer in-flight search
|
|
101
|
+
// keeps loading=true until it settles.
|
|
102
|
+
if (seq === _seq) loading.value = false
|
|
103
|
+
}
|
|
104
|
+
}, 200)
|
|
83
105
|
})
|
|
84
106
|
|
|
85
|
-
return { query, results }
|
|
107
|
+
return { query, results, loading }
|
|
86
108
|
})
|
|
87
109
|
|
|
88
110
|
/**
|
|
@@ -92,8 +114,10 @@ const _factory = createComposable((): UseContentSearchReturn => {
|
|
|
92
114
|
* `/_content/search-index.json`. Both MiniSearch and the index are loaded via
|
|
93
115
|
* dynamic import — neither is in the app bundle.
|
|
94
116
|
*
|
|
95
|
-
* Searches `title` and `description` fields.
|
|
96
|
-
*
|
|
117
|
+
* Searches `title` and `description` fields. Input is debounced (200 ms) so
|
|
118
|
+
* the index is not queried on every keystroke. `loading` becomes `true` as soon
|
|
119
|
+
* as the user starts typing and returns to `false` once results arrive. Results
|
|
120
|
+
* are empty when the query is empty.
|
|
97
121
|
*
|
|
98
122
|
* **SSR note**: search is always client-side. In SSR mode the component renders
|
|
99
123
|
* with empty results and hydrates on mount.
|
|
@@ -101,10 +125,11 @@ const _factory = createComposable((): UseContentSearchReturn => {
|
|
|
101
125
|
* @example
|
|
102
126
|
* ```ts
|
|
103
127
|
* component('site-search', () => {
|
|
104
|
-
* const { query, results } = useContentSearch()
|
|
128
|
+
* const { query, results, loading } = useContentSearch()
|
|
105
129
|
*
|
|
106
130
|
* return html`
|
|
107
131
|
* <input type="search" :model="${query}" placeholder="Search…" />
|
|
132
|
+
* ${loading.value ? html`<p>Searching…</p>` : ''}
|
|
108
133
|
* ${when(results.value.length > 0, () => html`
|
|
109
134
|
* <ul>
|
|
110
135
|
* ${each(results.value, r => html`
|