@sarimarcus/content-sites-ui 0.17.0 → 0.19.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 +13 -0
- package/README.md +54 -2
- package/package.json +5 -3
- package/src/composed/ArrowLinkGrid.astro +36 -0
- package/src/composed/RelatedContent.astro +135 -0
- package/src/composed/RelatedContentCard.astro +99 -0
- package/src/composed/index.ts +5 -0
- package/src/composed/related.ts +16 -0
- package/src/editorial/ArticleByline.astro +34 -0
- package/src/editorial/ArticleCard.astro +56 -0
- package/src/editorial/ArticleCategoryNav.astro +50 -0
- package/src/editorial/ArticleFeatureCard.astro +71 -0
- package/src/editorial/ArticleGrid.astro +23 -0
- package/src/editorial/ArticleKeyFacts.astro +54 -0
- package/src/editorial/ArticlesListingPage.astro +40 -0
- package/src/editorial/AuthorCard.astro +174 -0
- package/src/editorial/AuthorStrip.astro +52 -0
- package/src/editorial/InsiderTips.astro +102 -0
- package/src/editorial/NotFoundPage.astro +98 -0
- package/src/editorial/StaticProsePage.astro +48 -0
- package/src/editorial/index.ts +13 -0
- package/src/editorial/types.ts +68 -0
- package/src/layout/ListingShell.astro +32 -0
- package/src/layout/index.ts +1 -0
- package/src/search/SearchPage.astro +222 -0
- package/src/search/index.ts +2 -0
- package/src/search/searchPage.ts +591 -0
- package/src/styles/not-found.css +151 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
// Client controller of SearchPage: Pagefind search with type chips (a meta filter, applied client-side), an optional
|
|
2
|
+
// audience row (a native Pagefind filter), URL state, load-more paging and search analytics. The route calls
|
|
3
|
+
// initSearchPage once from its <script>; a hoisted script cannot read component props, so taxonomy and copy come in here.
|
|
4
|
+
import { appendResults, type PagefindResultFragment, type SearchTaxonomy, type SearchTypeMeta } from './renderResult.ts';
|
|
5
|
+
|
|
6
|
+
export interface SearchPageType extends SearchTypeMeta {
|
|
7
|
+
pluralLabel: string;
|
|
8
|
+
iconName: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SearchPageStrings {
|
|
12
|
+
heroEyebrow: string;
|
|
13
|
+
heroHeading: string;
|
|
14
|
+
heroSubhead: string;
|
|
15
|
+
inputPlaceholder: string;
|
|
16
|
+
inputLabel: string;
|
|
17
|
+
filterAllLabel: string;
|
|
18
|
+
filterGroupLabel: string;
|
|
19
|
+
resultsHeadingOne: string;
|
|
20
|
+
resultsHeadingMany: string;
|
|
21
|
+
resultsHeadingFiltered: string;
|
|
22
|
+
loadingLabel: string;
|
|
23
|
+
loadMoreLabel: string;
|
|
24
|
+
emptySuggestionsLabel: string;
|
|
25
|
+
zeroHeading: string;
|
|
26
|
+
zeroSubhead: string;
|
|
27
|
+
zeroFallbackLabel: string;
|
|
28
|
+
filterEmptyHeading: string;
|
|
29
|
+
filterEmptyClearLabel: string;
|
|
30
|
+
errorHeading: string;
|
|
31
|
+
errorSubhead: string;
|
|
32
|
+
errorRetryLabel: string;
|
|
33
|
+
/** Accessible name of the clear button. */
|
|
34
|
+
clearLabel: string;
|
|
35
|
+
/** Live-region announcements; `{q}` is the query, `{n}` the count. */
|
|
36
|
+
announceZero: string;
|
|
37
|
+
announceCategoryEmpty: string;
|
|
38
|
+
announceResultsOne: string;
|
|
39
|
+
announceResultsMany: string;
|
|
40
|
+
announceError: string;
|
|
41
|
+
/** Audience row copy, required when the page renders `audienceFacets`. */
|
|
42
|
+
audienceGroupLabel?: string;
|
|
43
|
+
audienceRowLabel?: string;
|
|
44
|
+
audienceAllLabel?: string;
|
|
45
|
+
resultsHeadingAudience?: string;
|
|
46
|
+
filterEmptyAudienceHeading?: string;
|
|
47
|
+
announceAudienceEmpty?: string;
|
|
48
|
+
/** `{n}` is replaced by the count. */
|
|
49
|
+
filterNoteOne?: string;
|
|
50
|
+
filterNoteMany?: string;
|
|
51
|
+
filterNoteAction?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SearchState = 'empty' | 'loading' | 'results' | 'zero' | 'filter-empty' | 'error';
|
|
55
|
+
|
|
56
|
+
export interface SearchPageConfig<T extends string> extends SearchTaxonomy<T> {
|
|
57
|
+
types: Record<T, SearchPageType>;
|
|
58
|
+
strings: SearchPageStrings;
|
|
59
|
+
pageSize?: number;
|
|
60
|
+
debounceMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare global {
|
|
64
|
+
interface Window {
|
|
65
|
+
track?: (eventName: string, params?: Record<string, unknown>) => void;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type PagefindHandle = { id: string; data: () => Promise<PagefindResultFragment> };
|
|
70
|
+
type PagefindModule = {
|
|
71
|
+
init: () => Promise<void>;
|
|
72
|
+
search: (
|
|
73
|
+
q: string,
|
|
74
|
+
options?: { filters?: Record<string, string> },
|
|
75
|
+
) => Promise<{
|
|
76
|
+
results: PagefindHandle[];
|
|
77
|
+
/** Matches for the query BEFORE any filter — how many the filter removed. */
|
|
78
|
+
unfilteredResultCount?: number;
|
|
79
|
+
/** Per-filter-value counts for this query, i.e. what each chip would yield. */
|
|
80
|
+
totalFilters?: Record<string, Record<string, number>>;
|
|
81
|
+
}>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function initSearchPage<T extends string>(config: SearchPageConfig<T>): void {
|
|
85
|
+
const { types, strings, pageSize = 12, debounceMs = 140 } = config;
|
|
86
|
+
const taxonomy: SearchTaxonomy<T> = { types, resolveType: config.resolveType, untitled: config.untitled };
|
|
87
|
+
const isSearchType = (value: string | null | undefined): value is T => value != null && value in types;
|
|
88
|
+
|
|
89
|
+
const input = document.getElementById('search-input') as HTMLInputElement;
|
|
90
|
+
const form = input.closest('form') as HTMLFormElement;
|
|
91
|
+
const clearButton = document.querySelector<HTMLButtonElement>('[data-search-clear]')!;
|
|
92
|
+
const filtersRoot = document.querySelector<HTMLElement>('.search-filters:not(.search-filters--audience)');
|
|
93
|
+
const audienceRoot = document.querySelector<HTMLElement>('.search-filters--audience');
|
|
94
|
+
const region = document.getElementById('search-results-region')!;
|
|
95
|
+
const announceEl = document.getElementById('search-announce')!;
|
|
96
|
+
const headingEl = document.querySelector<HTMLElement>('[data-search-results-heading]')!;
|
|
97
|
+
const listEl = document.querySelector<HTMLUListElement>('[data-search-results-list]')!;
|
|
98
|
+
const loadMoreBtn = document.querySelector<HTMLButtonElement>('[data-search-load-more]')!;
|
|
99
|
+
const zeroHeadingEl = document.querySelector<HTMLElement>('[data-search-zero-heading]')!;
|
|
100
|
+
const filterNoteEl = document.querySelector<HTMLElement>('[data-search-filter-note]')!;
|
|
101
|
+
const filterEmptyHeadingEl = document.querySelector<HTMLElement>(
|
|
102
|
+
'[data-search-filter-empty-heading]',
|
|
103
|
+
)!;
|
|
104
|
+
|
|
105
|
+
// Single source of truth for valid audience values: the chips the server
|
|
106
|
+
// rendered, which came from measured corpus counts.
|
|
107
|
+
const KNOWN_AUDIENCES = new Set(
|
|
108
|
+
Array.from(audienceRoot?.querySelectorAll<HTMLButtonElement>('[data-search-audience]') ?? [])
|
|
109
|
+
.map((b) => b.dataset.searchAudience || '')
|
|
110
|
+
.filter(Boolean),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const states = {} as Record<SearchState, HTMLElement>;
|
|
114
|
+
document.querySelectorAll<HTMLElement>('[data-search-state]').forEach((el) => {
|
|
115
|
+
const key = el.dataset.searchState as SearchState | undefined;
|
|
116
|
+
if (key) states[key] = el;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let pagefindAPI: PagefindModule | null = null;
|
|
120
|
+
let activeType: T | null = null;
|
|
121
|
+
let activeAudience: string | null = null;
|
|
122
|
+
let renderedCount = 0;
|
|
123
|
+
// Result handles are cheap (no network); fragments are materialized lazily.
|
|
124
|
+
let cachedHandles: PagefindHandle[] = [];
|
|
125
|
+
let cachedFragments: PagefindResultFragment[] = [];
|
|
126
|
+
let searchToken = 0;
|
|
127
|
+
let lastQuery = '';
|
|
128
|
+
let lastType: T | null = null;
|
|
129
|
+
let lastAudience: string | null = null;
|
|
130
|
+
|
|
131
|
+
function readURL() {
|
|
132
|
+
const params = new URLSearchParams(window.location.search);
|
|
133
|
+
const q = params.get('q')?.trim() ?? '';
|
|
134
|
+
const t = params.get('type');
|
|
135
|
+
const focus = params.get('focus') === '1';
|
|
136
|
+
const type = isSearchType(t) ? t : null;
|
|
137
|
+
// Validated against the rendered chips, so a hand-typed value that carries
|
|
138
|
+
// no content cannot put the page in a permanently empty filtered state.
|
|
139
|
+
const rawAudience = params.get('audience');
|
|
140
|
+
const audience = rawAudience && KNOWN_AUDIENCES.has(rawAudience) ? rawAudience : null;
|
|
141
|
+
return { q, type, audience, focus };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeURL(q: string, type: T | null, audience: string | null) {
|
|
145
|
+
const params = new URLSearchParams();
|
|
146
|
+
if (q) params.set('q', q);
|
|
147
|
+
if (type) params.set('type', type);
|
|
148
|
+
if (audience) params.set('audience', audience);
|
|
149
|
+
const qs = params.toString();
|
|
150
|
+
const next = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
|
151
|
+
window.history.replaceState(null, '', next);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function showState(name: SearchState, announceText?: string) {
|
|
155
|
+
(Object.keys(states) as SearchState[]).forEach((key) => {
|
|
156
|
+
states[key].hidden = key !== name;
|
|
157
|
+
});
|
|
158
|
+
region.setAttribute('aria-busy', String(name === 'loading'));
|
|
159
|
+
if (filtersRoot) filtersRoot.hidden = name === 'empty';
|
|
160
|
+
if (audienceRoot) audienceRoot.hidden = name === 'empty';
|
|
161
|
+
// Keep announcer quiet during loading/empty; only announce outcomes.
|
|
162
|
+
if (announceText !== undefined) {
|
|
163
|
+
announceEl.textContent = announceText;
|
|
164
|
+
} else if (name === 'loading' || name === 'empty') {
|
|
165
|
+
announceEl.textContent = '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function updateFilterChips(type: T | null) {
|
|
170
|
+
filtersRoot
|
|
171
|
+
?.querySelectorAll<HTMLButtonElement>('[data-search-filter]')
|
|
172
|
+
.forEach((btn) => {
|
|
173
|
+
const isActive = (btn.dataset.searchFilter || null) === (type || null);
|
|
174
|
+
btn.classList.toggle('search-chip--active', isActive);
|
|
175
|
+
btn.setAttribute('aria-pressed', String(isActive));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function updateAudienceChips(audience: string | null) {
|
|
180
|
+
audienceRoot
|
|
181
|
+
?.querySelectorAll<HTMLButtonElement>('[data-search-audience]')
|
|
182
|
+
.forEach((btn) => {
|
|
183
|
+
const isActive = (btn.dataset.searchAudience || null) === (audience || null);
|
|
184
|
+
btn.classList.toggle('search-chip--active', isActive);
|
|
185
|
+
btn.setAttribute('aria-pressed', String(isActive));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Repoint the chip counts at the current query. Server-rendered counts are
|
|
191
|
+
* corpus-wide item counts, which read as result counts and are off by orders
|
|
192
|
+
* of magnitude once a query is typed ("Couples 133" then 3 results). Pagefind
|
|
193
|
+
* returns per-value counts for the query in `totalFilters`, so use those and
|
|
194
|
+
* fall back to the corpus number when there is no query.
|
|
195
|
+
*/
|
|
196
|
+
function updateAudienceCounts(totals?: Record<string, number>) {
|
|
197
|
+
audienceRoot
|
|
198
|
+
?.querySelectorAll<HTMLButtonElement>('[data-search-audience]')
|
|
199
|
+
.forEach((btn) => {
|
|
200
|
+
const value = btn.dataset.searchAudience;
|
|
201
|
+
const countEl = btn.querySelector<HTMLElement>('.search-chip__count');
|
|
202
|
+
if (!value || !countEl) return; // the "Anyone" chip carries no count
|
|
203
|
+
const corpus = btn.dataset.searchAudienceTotal ?? '';
|
|
204
|
+
const n = totals ? totals[value] ?? 0 : Number(corpus);
|
|
205
|
+
countEl.textContent = String(n);
|
|
206
|
+
btn.classList.toggle('search-chip--empty', totals !== undefined && n === 0);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderFilterNote(hidden: number) {
|
|
211
|
+
if (hidden <= 0) {
|
|
212
|
+
filterNoteEl.hidden = true;
|
|
213
|
+
filterNoteEl.replaceChildren();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
filterNoteEl.replaceChildren();
|
|
217
|
+
filterNoteEl.appendChild(
|
|
218
|
+
document.createTextNode(
|
|
219
|
+
hidden === 1 ? strings.filterNoteOne ?? '' : (strings.filterNoteMany ?? '').replace('{n}', String(hidden)),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
const btn = document.createElement('button');
|
|
223
|
+
btn.type = 'button';
|
|
224
|
+
btn.className = 'search-filter-note__action';
|
|
225
|
+
btn.textContent = strings.filterNoteAction ?? '';
|
|
226
|
+
btn.setAttribute('data-search-clear-audience', '');
|
|
227
|
+
filterNoteEl.appendChild(btn);
|
|
228
|
+
filterNoteEl.hidden = false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function audienceLabel(value: string): string {
|
|
232
|
+
const btn = audienceRoot?.querySelector<HTMLButtonElement>(
|
|
233
|
+
`[data-search-audience="${value}"] span`,
|
|
234
|
+
);
|
|
235
|
+
return btn?.textContent?.trim() || value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderHeading(
|
|
239
|
+
el: HTMLElement,
|
|
240
|
+
count: number,
|
|
241
|
+
query: string,
|
|
242
|
+
typeFilter: T | null,
|
|
243
|
+
audienceFilter: string | null,
|
|
244
|
+
) {
|
|
245
|
+
el.replaceChildren();
|
|
246
|
+
const prefix =
|
|
247
|
+
count === 1 ? strings.resultsHeadingOne : `${count}${strings.resultsHeadingMany}`;
|
|
248
|
+
el.appendChild(document.createTextNode(prefix));
|
|
249
|
+
const queryStrong = document.createElement('strong');
|
|
250
|
+
queryStrong.textContent = `“${query}”`;
|
|
251
|
+
el.appendChild(queryStrong);
|
|
252
|
+
if (typeFilter) {
|
|
253
|
+
el.appendChild(document.createTextNode(strings.resultsHeadingFiltered));
|
|
254
|
+
const typeStrong = document.createElement('strong');
|
|
255
|
+
typeStrong.textContent = types[typeFilter].pluralLabel;
|
|
256
|
+
el.appendChild(typeStrong);
|
|
257
|
+
}
|
|
258
|
+
if (audienceFilter) {
|
|
259
|
+
el.appendChild(document.createTextNode(strings.resultsHeadingAudience ?? ''));
|
|
260
|
+
const audienceStrong = document.createElement('strong');
|
|
261
|
+
audienceStrong.textContent = audienceLabel(audienceFilter);
|
|
262
|
+
el.appendChild(audienceStrong);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderZeroHeading(el: HTMLElement, query: string) {
|
|
267
|
+
el.replaceChildren();
|
|
268
|
+
el.appendChild(document.createTextNode(strings.zeroHeading));
|
|
269
|
+
const strong = document.createElement('strong');
|
|
270
|
+
strong.textContent = `“${query}”`;
|
|
271
|
+
el.appendChild(strong);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function getPagefind(): Promise<PagefindModule> {
|
|
275
|
+
if (pagefindAPI) return pagefindAPI;
|
|
276
|
+
// @ts-expect-error — /pagefind/pagefind.js is emitted by the build, so it
|
|
277
|
+
// cannot resolve at type-check time. Runtime path is correct.
|
|
278
|
+
const mod = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as PagefindModule;
|
|
279
|
+
await mod.init();
|
|
280
|
+
pagefindAPI = mod;
|
|
281
|
+
return pagefindAPI;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runSearch(
|
|
285
|
+
query: string,
|
|
286
|
+
typeFilter: T | null,
|
|
287
|
+
audienceFilter: string | null = null,
|
|
288
|
+
) {
|
|
289
|
+
// Skip if neither query nor either filter changed since the last invocation.
|
|
290
|
+
if (query === lastQuery && typeFilter === lastType && audienceFilter === lastAudience) return;
|
|
291
|
+
lastQuery = query;
|
|
292
|
+
lastType = typeFilter;
|
|
293
|
+
lastAudience = audienceFilter;
|
|
294
|
+
|
|
295
|
+
writeURL(query, typeFilter, audienceFilter);
|
|
296
|
+
const myToken = ++searchToken;
|
|
297
|
+
|
|
298
|
+
if (!query) {
|
|
299
|
+
updateAudienceCounts(undefined); // back to corpus counts
|
|
300
|
+
renderFilterNote(0);
|
|
301
|
+
showState('empty');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
showState('loading');
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const pf = await getPagefind();
|
|
309
|
+
// `audience` IS a native Pagefind filter, so it narrows the result set in
|
|
310
|
+
// the index rather than in JS — no fragment fetch to count it.
|
|
311
|
+
const result = await pf.search(
|
|
312
|
+
query,
|
|
313
|
+
audienceFilter ? { filters: { audience: audienceFilter } } : undefined,
|
|
314
|
+
);
|
|
315
|
+
if (myToken !== searchToken) return;
|
|
316
|
+
|
|
317
|
+
cachedHandles = result.results;
|
|
318
|
+
|
|
319
|
+
// Query-aware chip counts. `totalFilters` carries per-value counts FOR THIS
|
|
320
|
+
// QUERY (verified: couples=41 on "salmorejo" vs 156 on "cordoba"), and it
|
|
321
|
+
// deliberately ignores the audience filter itself, so one filtered search
|
|
322
|
+
// yields every chip's count. Pagefind only populates it when a filter is
|
|
323
|
+
// applied, though — so with no audience selected, harvest the same numbers
|
|
324
|
+
// from one extra index search whose results are discarded. Index-local, no
|
|
325
|
+
// network, and it keeps the chips from showing corpus counts next to a
|
|
326
|
+
// query's results ("Couples 133" above 3 salmorejo matches).
|
|
327
|
+
let audienceTotals = result.totalFilters?.audience;
|
|
328
|
+
if (!audienceTotals && KNOWN_AUDIENCES.size > 0) {
|
|
329
|
+
try {
|
|
330
|
+
const probe = await pf.search(query, {
|
|
331
|
+
filters: { audience: [...KNOWN_AUDIENCES][0] },
|
|
332
|
+
});
|
|
333
|
+
if (myToken !== searchToken) return;
|
|
334
|
+
audienceTotals = probe.totalFilters?.audience;
|
|
335
|
+
} catch {
|
|
336
|
+
// Counts simply stay as they were — not worth failing a search over.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
updateAudienceCounts(audienceTotals);
|
|
340
|
+
|
|
341
|
+
const unfilteredCount = result.unfilteredResultCount ?? cachedHandles.length;
|
|
342
|
+
renderFilterNote(audienceFilter ? unfilteredCount - cachedHandles.length : 0);
|
|
343
|
+
|
|
344
|
+
if (cachedHandles.length === 0) {
|
|
345
|
+
// With a filter on, offer the clear-filter affordance instead of the
|
|
346
|
+
// dead-end zero state: the query itself may be perfectly good.
|
|
347
|
+
if (audienceFilter) {
|
|
348
|
+
filterEmptyHeadingEl.textContent = strings.filterEmptyAudienceHeading ?? strings.filterEmptyHeading;
|
|
349
|
+
renderFilterNote(0);
|
|
350
|
+
showState('filter-empty', strings.announceAudienceEmpty ?? '');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
renderZeroHeading(zeroHeadingEl, query);
|
|
354
|
+
showState('zero', strings.announceZero.replace('{q}', () => query));
|
|
355
|
+
// No `search_audience_filter` here: the audience branch above returns
|
|
356
|
+
// first, so this event is only ever reached with no audience filter on.
|
|
357
|
+
window.track?.('search_zero_results', {
|
|
358
|
+
search_term: query,
|
|
359
|
+
search_type_filter: typeFilter ?? '',
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (typeFilter) {
|
|
365
|
+
// `type` is NOT a Pagefind filter — it lives in each fragment's meta,
|
|
366
|
+
// so it can only be applied after materializing them. That is why this
|
|
367
|
+
// branch fetches all handles while the audience filter above does not.
|
|
368
|
+
// Making it native would mean emitting data-pagefind-filter="type:…"
|
|
369
|
+
// from every detail template.
|
|
370
|
+
const all = await Promise.all(cachedHandles.map((h) => h.data()));
|
|
371
|
+
if (myToken !== searchToken) return;
|
|
372
|
+
cachedFragments = all.filter((f) => taxonomy.resolveType(f.meta) === typeFilter);
|
|
373
|
+
|
|
374
|
+
if (cachedFragments.length === 0) {
|
|
375
|
+
filterEmptyHeadingEl.textContent = strings.filterEmptyHeading;
|
|
376
|
+
showState('filter-empty', strings.announceCategoryEmpty);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
listEl.replaceChildren();
|
|
381
|
+
appendResults(listEl, cachedFragments.slice(0, pageSize), 0, taxonomy);
|
|
382
|
+
renderedCount = Math.min(pageSize, cachedFragments.length);
|
|
383
|
+
loadMoreBtn.hidden = cachedFragments.length <= pageSize;
|
|
384
|
+
renderHeading(headingEl, cachedFragments.length, query, typeFilter, audienceFilter);
|
|
385
|
+
} else {
|
|
386
|
+
// No filter: fetch only the fragments we render. Load-more fetches more.
|
|
387
|
+
const firstBatch = await Promise.all(
|
|
388
|
+
cachedHandles.slice(0, pageSize).map((h) => h.data()),
|
|
389
|
+
);
|
|
390
|
+
if (myToken !== searchToken) return;
|
|
391
|
+
cachedFragments = firstBatch;
|
|
392
|
+
|
|
393
|
+
listEl.replaceChildren();
|
|
394
|
+
appendResults(listEl, firstBatch, 0, taxonomy);
|
|
395
|
+
renderedCount = firstBatch.length;
|
|
396
|
+
loadMoreBtn.hidden = cachedHandles.length <= pageSize;
|
|
397
|
+
renderHeading(headingEl, cachedHandles.length, query, typeFilter, audienceFilter);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const resultCount = typeFilter ? cachedFragments.length : cachedHandles.length;
|
|
401
|
+
const announce = resultCount === 1 ? strings.announceResultsOne : strings.announceResultsMany;
|
|
402
|
+
showState('results', announce.replace('{n}', String(resultCount)).replace('{q}', () => query));
|
|
403
|
+
|
|
404
|
+
window.track?.('search_query', {
|
|
405
|
+
search_term: query,
|
|
406
|
+
search_type_filter: typeFilter ?? '',
|
|
407
|
+
...(audienceRoot ? { search_audience_filter: audienceFilter ?? '' } : {}),
|
|
408
|
+
search_results_count: resultCount,
|
|
409
|
+
});
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.error('[search] failed', err);
|
|
412
|
+
if (myToken !== searchToken) return;
|
|
413
|
+
showState('error', strings.announceError);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function debounce<A extends unknown[]>(
|
|
418
|
+
fn: (...args: A) => void,
|
|
419
|
+
ms: number,
|
|
420
|
+
): (...args: A) => void {
|
|
421
|
+
let t: number | undefined;
|
|
422
|
+
return (...args: A) => {
|
|
423
|
+
clearTimeout(t);
|
|
424
|
+
t = window.setTimeout(() => fn(...args), ms);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const debouncedRun = debounce(runSearch, debounceMs);
|
|
429
|
+
|
|
430
|
+
form.addEventListener('submit', (e) => e.preventDefault());
|
|
431
|
+
|
|
432
|
+
input.addEventListener('input', () => {
|
|
433
|
+
const q = input.value.trim();
|
|
434
|
+
clearButton.hidden = q.length === 0;
|
|
435
|
+
debouncedRun(q, activeType, activeAudience);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
input.addEventListener('keydown', (e) => {
|
|
439
|
+
if (e.key === 'Escape' && input.value) {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
input.value = '';
|
|
442
|
+
clearButton.hidden = true;
|
|
443
|
+
runSearch('', activeType, activeAudience);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
clearButton.addEventListener('click', () => {
|
|
448
|
+
input.value = '';
|
|
449
|
+
clearButton.hidden = true;
|
|
450
|
+
input.focus();
|
|
451
|
+
runSearch('', activeType, activeAudience);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
filtersRoot?.addEventListener('click', (e) => {
|
|
455
|
+
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-search-filter]');
|
|
456
|
+
if (!btn) return;
|
|
457
|
+
const raw = btn.dataset.searchFilter || '';
|
|
458
|
+
const newType = isSearchType(raw) ? raw : null;
|
|
459
|
+
activeType = newType;
|
|
460
|
+
updateFilterChips(newType);
|
|
461
|
+
runSearch(input.value.trim(), newType, activeAudience);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
audienceRoot?.addEventListener('click', (e) => {
|
|
465
|
+
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-search-audience]');
|
|
466
|
+
if (!btn) return;
|
|
467
|
+
const raw = btn.dataset.searchAudience || '';
|
|
468
|
+
const newAudience = KNOWN_AUDIENCES.has(raw) ? raw : null;
|
|
469
|
+
activeAudience = newAudience;
|
|
470
|
+
updateAudienceChips(newAudience);
|
|
471
|
+
runSearch(input.value.trim(), activeType, newAudience);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
region.addEventListener('click', (e) => {
|
|
475
|
+
const target = e.target as HTMLElement;
|
|
476
|
+
|
|
477
|
+
const sugBtn = target.closest<HTMLButtonElement>('[data-search-suggest]');
|
|
478
|
+
if (sugBtn) {
|
|
479
|
+
const q = sugBtn.dataset.searchSuggest || '';
|
|
480
|
+
input.value = q;
|
|
481
|
+
clearButton.hidden = q.length === 0;
|
|
482
|
+
input.focus();
|
|
483
|
+
runSearch(q, activeType, activeAudience);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (target.closest<HTMLElement>('[data-search-clear-audience]')) {
|
|
488
|
+
// Narrower than [data-search-clear-filter]: the note is specifically about
|
|
489
|
+
// what the AUDIENCE filter removed, so it must not also drop a type filter
|
|
490
|
+
// the reader deliberately set.
|
|
491
|
+
activeAudience = null;
|
|
492
|
+
updateAudienceChips(null);
|
|
493
|
+
runSearch(input.value.trim(), activeType, null);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (target.closest<HTMLElement>('[data-search-clear-filter]')) {
|
|
498
|
+
// Clears BOTH filters: the empty state does not say which one excluded
|
|
499
|
+
// everything, so clearing only one can leave the page still empty.
|
|
500
|
+
activeType = null;
|
|
501
|
+
activeAudience = null;
|
|
502
|
+
updateFilterChips(null);
|
|
503
|
+
updateAudienceChips(null);
|
|
504
|
+
runSearch(input.value.trim(), null, null);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (target.closest<HTMLElement>('[data-search-retry]')) {
|
|
509
|
+
lastQuery = '';
|
|
510
|
+
lastType = null;
|
|
511
|
+
lastAudience = null;
|
|
512
|
+
runSearch(input.value.trim(), activeType, activeAudience);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
loadMoreBtn.addEventListener('click', async () => {
|
|
517
|
+
if (activeType) {
|
|
518
|
+
// All type-filtered fragments are already in cachedFragments. An audience
|
|
519
|
+
// filter needs no branch here: it narrowed cachedHandles server-side, so
|
|
520
|
+
// the paging path below is already correct for it.
|
|
521
|
+
const next = cachedFragments.slice(renderedCount, renderedCount + pageSize);
|
|
522
|
+
if (next.length === 0) return;
|
|
523
|
+
appendResults(listEl, next, renderedCount, taxonomy);
|
|
524
|
+
renderedCount += next.length;
|
|
525
|
+
if (renderedCount >= cachedFragments.length) loadMoreBtn.hidden = true;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const nextHandles = cachedHandles.slice(renderedCount, renderedCount + pageSize);
|
|
530
|
+
if (nextHandles.length === 0) return;
|
|
531
|
+
const fragments = await Promise.all(nextHandles.map((h) => h.data()));
|
|
532
|
+
cachedFragments.push(...fragments);
|
|
533
|
+
appendResults(listEl, fragments, renderedCount, taxonomy);
|
|
534
|
+
renderedCount += fragments.length;
|
|
535
|
+
if (renderedCount >= cachedHandles.length) loadMoreBtn.hidden = true;
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
listEl.addEventListener('click', (e) => {
|
|
539
|
+
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
|
540
|
+
'[data-search-result-link]',
|
|
541
|
+
);
|
|
542
|
+
if (!link) return;
|
|
543
|
+
const card = link.closest<HTMLElement>('.search-card');
|
|
544
|
+
window.track?.('search_result_click', {
|
|
545
|
+
search_term: input.value.trim(),
|
|
546
|
+
result_position: card ? Number(card.dataset.index ?? 0) + 1 : 0,
|
|
547
|
+
result_url: link.href,
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
window.addEventListener('popstate', () => {
|
|
552
|
+
const next = readURL();
|
|
553
|
+
input.value = next.q;
|
|
554
|
+
clearButton.hidden = next.q.length === 0;
|
|
555
|
+
activeType = next.type;
|
|
556
|
+
activeAudience = next.audience;
|
|
557
|
+
updateFilterChips(next.type);
|
|
558
|
+
updateAudienceChips(next.audience);
|
|
559
|
+
runSearch(next.q, next.type, next.audience);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const initial = readURL();
|
|
563
|
+
if (initial.q) {
|
|
564
|
+
input.value = initial.q;
|
|
565
|
+
clearButton.hidden = false;
|
|
566
|
+
}
|
|
567
|
+
activeType = initial.type;
|
|
568
|
+
activeAudience = initial.audience;
|
|
569
|
+
updateFilterChips(initial.type);
|
|
570
|
+
updateAudienceChips(initial.audience);
|
|
571
|
+
if (initial.q) {
|
|
572
|
+
runSearch(initial.q, initial.type, initial.audience);
|
|
573
|
+
}
|
|
574
|
+
if (initial.focus || !initial.q) {
|
|
575
|
+
const claimFocus = () => {
|
|
576
|
+
if (document.activeElement === input) return;
|
|
577
|
+
input.focus({ preventScroll: true });
|
|
578
|
+
if (input.value) {
|
|
579
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
requestAnimationFrame(claimFocus);
|
|
583
|
+
// Cookie consent banner may steal focus ~1.5s after load (deferred init).
|
|
584
|
+
// Reclaim once if the user hasn't moved focus elsewhere.
|
|
585
|
+
if (initial.focus) {
|
|
586
|
+
window.setTimeout(() => {
|
|
587
|
+
if (document.activeElement === document.body) claimFocus();
|
|
588
|
+
}, 1700);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|