@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 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: add customColors support for jitCss configuration and implement related tests (b8061ef)
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. Results are empty until at least
25
- * 2 characters are entered.
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;CAC9C;AAmCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,gBAAgB,IAAI,sBAAsB,CAEzD"}
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
- watch(query, async (q) => {
48
- const seq = ++_seq;
49
- if (!q || q.length < 2) {
50
- results.value = [];
51
- return;
48
+ let _debounceTimer = null;
49
+ watch(query, (q) => {
50
+ if (_debounceTimer !== null) {
51
+ clearTimeout(_debounceTimer);
52
+ _debounceTimer = null;
52
53
  }
53
- try {
54
- const index = await loadIndex();
55
- if (seq !== _seq)
56
- return; // stale — a newer query is in flight
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. Results are empty until at least
75
- * 2 characters are entered.
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;AASD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,GAA2B,EAAE;IAC7D,MAAM,KAAK,GAAG,GAAG,CAAC,EAAE,CAAC,CAAA;IACrB,MAAM,OAAO,GAAG,GAAG,CAAwB,EAAE,CAAC,CAAA;IAE9C,0BAA0B;IAC1B,cAAc,CAAC,GAAG,EAAE;QAClB,SAAS,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAuC,CAAC,CAAC,CAAA;IAClE,CAAC,CAAC,CAAA;IAEF,mDAAmD;IACnD,IAAI,IAAI,GAAG,CAAC,CAAA;IAEZ,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAS,EAAE,EAAE;QAC/B,MAAM,GAAG,GAAG,EAAE,IAAI,CAAA;QAElB,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACvB,OAAO,CAAC,KAAK,GAAG,EAAE,CAAA;YAClB,OAAM;QACR,CAAC;QAED,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,SAAS,EAA+E,CAAA;YAC5G,IAAI,GAAG,KAAK,IAAI;gBAAE,OAAM,CAAC,qCAAqC;YAC9D,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAA0B,CAAA;QAC5E,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,GAAG,KAAK,IAAI;gBAAE,OAAM;YACxB,OAAO,CAAC,KAAK,GAAG,EAAE,CAAA;QACpB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,CAAA;AAC3B,CAAC,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO,QAAQ,EAAE,CAAA;AACnB,CAAC"}
1
+ {"version":3,"file":"use-content-search.js","sourceRoot":"","sources":["../../../src/runtime/composables/use-content-search.ts"],"names":[],"mappings":"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"}
@@ -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 `results` refs that update reactively as the user types.
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 activates when `query.value.length >= 2`. MiniSearch is loaded once and cached for the lifetime of the page. Searched fields are `title` and `description`.
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 no results before typing 2 chars', () => {
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('not.exist')
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 2-char query', () => {
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 2+ characters into the search box triggers MiniSearch results.
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 >= 2 ? html`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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 (query reactive state, stale-seq
11
- * guard, useOnConnected pre-warm) requires a component context provided by the
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, async (q: string) => {
68
- const seq = ++_seq
71
+ watch(query, (q: string) => {
72
+ if (_debounceTimer !== null) {
73
+ clearTimeout(_debounceTimer)
74
+ _debounceTimer = null
75
+ }
69
76
 
70
- if (!q || q.length < 2) {
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
- try {
76
- const index = await loadIndex() as { search(q: string, opts?: { prefix?: boolean }): ContentSearchResult[] }
77
- if (seq !== _seq) return // stale — a newer query is in flight
78
- results.value = index.search(q, { prefix: true }) as ContentSearchResult[]
79
- } catch {
80
- if (seq !== _seq) return
81
- results.value = []
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. Results are empty until at least
96
- * 2 characters are entered.
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`