@sprig-and-prose/sprig-ui-csr 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.
@@ -0,0 +1,451 @@
1
+ <script>
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { universeGraph } from '../data/universeStore.js';
4
+ import { getNodeRoute, getAncestorChain } from '../data/universeStore.js';
5
+ import { getDisplayTitle } from '../format/title.js';
6
+ import { navigate } from '../router.js';
7
+
8
+ /**
9
+ * @typedef {import('../data/universeStore.js').UniverseGraph} UniverseGraph
10
+ * @typedef {import('../data/universeStore.js').NodeModel} NodeModel
11
+ * @typedef {{ node: NodeModel, name: string, kind: string, id: string, route: string, context: string | null }} SearchItem
12
+ */
13
+
14
+ /** @type {HTMLInputElement | null} */
15
+ let searchInput = null;
16
+ let query = '';
17
+ let searchIndex = /** @type {SearchItem[]} */ ([]);
18
+ let activeIndex = /** @type {number | null} */ (null);
19
+ let listboxId = 'search-results-listbox';
20
+ /** @type {HTMLDivElement | null} */
21
+ let resultsDropdown = null;
22
+ let isManuallyClosed = false;
23
+
24
+ /**
25
+ * Build context string for a node
26
+ * @param {UniverseGraph} graph
27
+ * @param {NodeModel} node
28
+ * @returns {string | null}
29
+ */
30
+ function buildContext(graph, node) {
31
+ if (node.kind === 'series') {
32
+ return null;
33
+ }
34
+
35
+ if (node.kind === 'book') {
36
+ if (!node.parent) return null;
37
+ const series = graph.nodes[node.parent];
38
+ if (!series || series.kind !== 'series') return null;
39
+ return `Book in ${getDisplayTitle(series)}`;
40
+ }
41
+
42
+ if (node.kind === 'chapter') {
43
+ const ancestors = getAncestorChain(graph, node);
44
+ if (ancestors.length === 0) return null;
45
+
46
+ const book = ancestors.find(a => a.kind === 'book');
47
+ const series = ancestors.find(a => a.kind === 'series');
48
+
49
+ if (book && series) {
50
+ return `in ${getDisplayTitle(book)} (in ${getDisplayTitle(series)})`;
51
+ } else if (book) {
52
+ return `in ${getDisplayTitle(book)}`;
53
+ } else if (series) {
54
+ return `in ${getDisplayTitle(series)}`;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Build search index from universe graph
64
+ * @param {UniverseGraph | null} graph
65
+ * @returns {SearchItem[]}
66
+ */
67
+ function buildSearchIndex(graph) {
68
+ if (!graph || !graph.nodes) return [];
69
+
70
+ const items = [];
71
+ const searchableKinds = ['series', 'book', 'chapter'];
72
+
73
+ for (const node of Object.values(graph.nodes)) {
74
+ if (!node || !searchableKinds.includes(node.kind)) continue;
75
+
76
+ const route = getNodeRoute(node);
77
+ const context = buildContext(graph, node);
78
+
79
+ items.push({
80
+ node,
81
+ name: node.name,
82
+ kind: node.kind,
83
+ id: node.id,
84
+ route,
85
+ context,
86
+ });
87
+ }
88
+
89
+ return items;
90
+ }
91
+
92
+ /**
93
+ * Search nodes with ranking
94
+ * @param {SearchItem[]} index
95
+ * @param {string} searchQuery
96
+ * @returns {SearchItem[]}
97
+ */
98
+ function searchNodes(index, searchQuery) {
99
+ if (!searchQuery.trim()) return [];
100
+
101
+ const queryLower = searchQuery.toLowerCase().trim();
102
+ const prefixMatches = [];
103
+ const substringMatches = [];
104
+
105
+ for (const item of index) {
106
+ const nameLower = item.name.toLowerCase();
107
+
108
+ if (nameLower.startsWith(queryLower)) {
109
+ prefixMatches.push(item);
110
+ } else if (nameLower.includes(queryLower)) {
111
+ substringMatches.push(item);
112
+ }
113
+ }
114
+
115
+ prefixMatches.sort((a, b) => a.name.localeCompare(b.name));
116
+ substringMatches.sort((a, b) => a.name.localeCompare(b.name));
117
+
118
+ const results = [...prefixMatches, ...substringMatches];
119
+ return results.slice(0, 10);
120
+ }
121
+
122
+ // Build search index reactively when graph changes
123
+ $: {
124
+ if ($universeGraph) {
125
+ searchIndex = buildSearchIndex($universeGraph);
126
+ }
127
+ }
128
+
129
+ // Perform search reactively
130
+ $: results = query.trim() ? searchNodes(searchIndex, query) : [];
131
+
132
+ // Reset active index and manual close flag when query or results change
133
+ $: {
134
+ activeIndex = null;
135
+ if (query.trim().length > 0) {
136
+ isManuallyClosed = false;
137
+ }
138
+ }
139
+
140
+ // Compute isOpen: show dropdown if we have results, query is not empty, and not manually closed
141
+ $: isOpen = results.length > 0 && query.trim().length > 0 && !isManuallyClosed;
142
+
143
+ /**
144
+ * Scroll active item into view
145
+ */
146
+ function scrollActiveIntoView() {
147
+ if (activeIndex === null || !resultsDropdown) return;
148
+
149
+ const optionId = `search-option-${activeIndex}`;
150
+ const optionElement = resultsDropdown.querySelector(`#${optionId}`);
151
+ if (optionElement) {
152
+ optionElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Handle keyboard navigation in search input
158
+ * @param {KeyboardEvent} event
159
+ */
160
+ function handleInputKeydown(event) {
161
+ if (event.key === 'Tab') {
162
+ return;
163
+ }
164
+
165
+ if (event.key === 'ArrowDown') {
166
+ event.preventDefault();
167
+ if (!isOpen || results.length === 0) return;
168
+
169
+ if (activeIndex === null) {
170
+ activeIndex = 0;
171
+ } else {
172
+ activeIndex = Math.min(activeIndex + 1, results.length - 1);
173
+ }
174
+ scrollActiveIntoView();
175
+ return;
176
+ }
177
+
178
+ if (event.key === 'ArrowUp') {
179
+ event.preventDefault();
180
+ if (!isOpen || results.length === 0) return;
181
+
182
+ if (activeIndex === null) {
183
+ activeIndex = results.length - 1;
184
+ } else {
185
+ activeIndex = Math.max(activeIndex - 1, 0);
186
+ }
187
+ scrollActiveIntoView();
188
+ return;
189
+ }
190
+
191
+ if (event.key === 'Enter' && isOpen && activeIndex !== null && results[activeIndex]) {
192
+ event.preventDefault();
193
+ const item = results[activeIndex];
194
+ handleResultClick(item);
195
+ return;
196
+ }
197
+
198
+ if (event.key === 'Escape') {
199
+ event.preventDefault();
200
+ isManuallyClosed = true;
201
+ activeIndex = null;
202
+ searchInput?.focus();
203
+ return;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Handle global keyboard shortcuts (for `/` to focus)
209
+ * @param {KeyboardEvent} event
210
+ */
211
+ function handleGlobalKeydown(event) {
212
+ const activeElement = document.activeElement;
213
+ const isInputElement = activeElement && (
214
+ activeElement.tagName === 'INPUT' ||
215
+ activeElement.tagName === 'TEXTAREA' ||
216
+ (activeElement instanceof HTMLElement && activeElement.isContentEditable)
217
+ );
218
+
219
+ if (event.key === '/' && !isInputElement) {
220
+ event.preventDefault();
221
+ searchInput?.focus();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Handle result click
227
+ * @param {SearchItem} item
228
+ */
229
+ function handleResultClick(item) {
230
+ query = '';
231
+ isOpen = false;
232
+ activeIndex = null;
233
+ navigate(item.route);
234
+ // Trigger route change event
235
+ window.dispatchEvent(new PopStateEvent('popstate'));
236
+ }
237
+
238
+ /**
239
+ * Handle mouse hover on result item
240
+ * @param {number} index
241
+ */
242
+ function handleResultHover(index) {
243
+ activeIndex = index;
244
+ }
245
+
246
+ onMount(() => {
247
+ window.addEventListener('keydown', handleGlobalKeydown);
248
+ });
249
+
250
+ onDestroy(() => {
251
+ if (typeof window !== 'undefined') {
252
+ window.removeEventListener('keydown', handleGlobalKeydown);
253
+ }
254
+ });
255
+ </script>
256
+
257
+ <div class="search-container">
258
+ <div class="search-wrapper">
259
+ <input
260
+ bind:this={searchInput}
261
+ type="text"
262
+ placeholder="Search Series, Books, Chapters… (Press / to focus)"
263
+ bind:value={query}
264
+ class="search-input"
265
+ role="combobox"
266
+ aria-label="Search"
267
+ aria-expanded={isOpen}
268
+ aria-controls={listboxId}
269
+ aria-activedescendant={activeIndex !== null ? `search-option-${activeIndex}` : undefined}
270
+ aria-autocomplete="list"
271
+ on:keydown={handleInputKeydown}
272
+ />
273
+
274
+ {#if isOpen && results.length > 0}
275
+ <div
276
+ bind:this={resultsDropdown}
277
+ class="results-dropdown"
278
+ role="listbox"
279
+ id={listboxId}
280
+ >
281
+ {#each results as item, index (item.id)}
282
+ {@const optionId = `search-option-${index}`}
283
+ {@const isActive = activeIndex === index}
284
+ <a
285
+ href={item.route}
286
+ class="result-item"
287
+ class:result-item--active={isActive}
288
+ role="option"
289
+ id={optionId}
290
+ aria-selected={isActive}
291
+ on:click|preventDefault={() => handleResultClick(item)}
292
+ on:mouseenter={() => handleResultHover(index)}
293
+ >
294
+ <div class="result-content">
295
+ <div class="result-title">{getDisplayTitle(item.node)}</div>
296
+ <div class="result-meta">
297
+ <span class="result-kind">{item.kind}</span>
298
+ {#if item.context}
299
+ <span class="result-context">{item.context}</span>
300
+ {/if}
301
+ </div>
302
+ </div>
303
+ </a>
304
+ {/each}
305
+ </div>
306
+ {/if}
307
+ </div>
308
+ </div>
309
+
310
+ <style>
311
+ .search-container {
312
+ display: flex;
313
+ flex: 1;
314
+ }
315
+
316
+ .search-wrapper {
317
+ position: relative;
318
+ max-width: 500px;
319
+ width: 100%;
320
+ }
321
+
322
+ .search-input {
323
+ width: 100%;
324
+ padding: 10px 14px;
325
+ font-family: var(--font-ui);
326
+ font-size: var(--sp-font-small);
327
+ color: var(--text-color);
328
+ background: var(--card-bg);
329
+ border: none;
330
+ border-radius: 4px;
331
+ outline: none;
332
+ transition: box-shadow 0.2s;
333
+ }
334
+
335
+ .search-input:focus {
336
+ box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
337
+ }
338
+
339
+ @media (prefers-color-scheme: dark) {
340
+ .search-input:focus {
341
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
342
+ }
343
+ }
344
+
345
+ :global(html[data-theme="dark"]) .search-input:focus {
346
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
347
+ }
348
+
349
+ .search-input::placeholder {
350
+ color: var(--text-tertiary);
351
+ }
352
+
353
+ .results-dropdown {
354
+ position: absolute;
355
+ top: calc(100% + 4px);
356
+ left: 0;
357
+ right: 0;
358
+ background: var(--bg-color);
359
+ border: 1px solid var(--border-color);
360
+ border-radius: 4px;
361
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
362
+ max-height: 400px;
363
+ overflow-y: auto;
364
+ z-index: 1000;
365
+ margin-top: 4px;
366
+ }
367
+
368
+ @media (prefers-color-scheme: dark) {
369
+ .results-dropdown {
370
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
371
+ }
372
+ }
373
+
374
+ :global(html[data-theme="dark"]) .results-dropdown {
375
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
376
+ }
377
+
378
+ .result-item {
379
+ display: block;
380
+ padding: 12px 14px;
381
+ text-decoration: none;
382
+ color: inherit;
383
+ border-bottom: 1px solid var(--hairline);
384
+ transition: background-color 0.15s;
385
+ cursor: pointer;
386
+ }
387
+
388
+ .result-item:last-child {
389
+ border-bottom: none;
390
+ }
391
+
392
+ .result-item:hover,
393
+ .result-item:focus {
394
+ background: var(--hover);
395
+ outline: none;
396
+ }
397
+
398
+ .result-item:focus-visible {
399
+ outline: 2px solid var(--text-tertiary);
400
+ outline-offset: -2px;
401
+ }
402
+
403
+ .result-item--active {
404
+ background: var(--hover);
405
+ }
406
+
407
+ .result-item--active .result-title {
408
+ text-decoration: underline;
409
+ text-decoration-thickness: 1px;
410
+ text-underline-offset: 2px;
411
+ text-decoration-color: var(--text-secondary);
412
+ }
413
+
414
+ .result-content {
415
+ display: flex;
416
+ flex-direction: column;
417
+ gap: 4px;
418
+ }
419
+
420
+ .result-title {
421
+ font-family: var(--font-prose);
422
+ font-size: var(--sp-font-body);
423
+ color: var(--text-color);
424
+ font-weight: 400;
425
+ }
426
+
427
+ .result-meta {
428
+ display: flex;
429
+ align-items: center;
430
+ gap: 8px;
431
+ font-family: var(--font-ui);
432
+ font-size: var(--sp-font-tiny);
433
+ }
434
+
435
+ .result-kind {
436
+ color: var(--text-tertiary);
437
+ text-transform: capitalize;
438
+ font-weight: 400;
439
+ }
440
+
441
+ .result-context {
442
+ color: var(--text-secondary);
443
+ }
444
+
445
+ .result-context::before {
446
+ content: '·';
447
+ margin-right: 4px;
448
+ color: var(--text-tertiary);
449
+ }
450
+ </style>
451
+
@@ -0,0 +1,116 @@
1
+ <script>
2
+ import { getDisplayTitle } from '../format/title.js';
3
+
4
+ /** @type {string} */
5
+ export let title;
6
+
7
+ /** @type {string | null} */
8
+ export let subtitle = null;
9
+
10
+ /** @type {any} */
11
+ export let subtitleNode = null;
12
+ </script>
13
+
14
+ <header class="header">
15
+ <div>
16
+ <h1 class="title">{title}</h1>
17
+ {#if subtitle}
18
+ <p class="subtitle">{@html subtitle}</p>
19
+ {:else if subtitleNode}
20
+ <p class="subtitle">{getDisplayTitle(subtitleNode)}</p>
21
+ {/if}
22
+ </div>
23
+ </header>
24
+
25
+ <style>
26
+ .header {
27
+ margin-bottom: 48px;
28
+ }
29
+
30
+ .title {
31
+ font-size: var(--sp-font-h1);
32
+ font-weight: 400;
33
+ line-height: 1.2;
34
+ margin-bottom: 0;
35
+ color: var(--text-color);
36
+ letter-spacing: -0.5px;
37
+ font-family: var(--font-prose);
38
+ }
39
+
40
+ .subtitle {
41
+ margin: 0;
42
+ font-size: var(--sp-font-body);
43
+ line-height: 1.6;
44
+ color: var(--text-color);
45
+ font-family: var(--font-ui);
46
+ margin-top: 0;
47
+ }
48
+
49
+ .subtitle :global(a) {
50
+ color: inherit;
51
+ text-decoration: underline;
52
+ text-decoration-thickness: 1px;
53
+ text-underline-offset: 0.25rem;
54
+ text-decoration-color: rgba(0, 0, 0, 0.25);
55
+ transition: text-decoration-thickness 150ms ease, text-decoration-color 150ms ease;
56
+ }
57
+
58
+ @media (prefers-color-scheme: dark) {
59
+ :root:not([data-theme="light"]) .subtitle :global(a) {
60
+ text-decoration-color: rgba(255, 255, 255, 0.35);
61
+ }
62
+ }
63
+
64
+ :global(html[data-theme="dark"]) .subtitle :global(a) {
65
+ text-decoration-color: rgba(255, 255, 255, 0.35);
66
+ }
67
+
68
+ :global(html[data-theme="light"]) .subtitle :global(a) {
69
+ text-decoration-color: rgba(0, 0, 0, 0.25);
70
+ }
71
+
72
+ .subtitle :global(a:hover) {
73
+ text-decoration-thickness: 1.5px;
74
+ text-decoration-color: rgba(0, 0, 0, 0.4);
75
+ }
76
+
77
+ @media (prefers-color-scheme: dark) {
78
+ :root:not([data-theme="light"]) .subtitle :global(a:hover) {
79
+ text-decoration-color: rgba(255, 255, 255, 0.5);
80
+ }
81
+ }
82
+
83
+ :global(html[data-theme="dark"]) .subtitle :global(a:hover) {
84
+ text-decoration-color: rgba(255, 255, 255, 0.5);
85
+ }
86
+
87
+ :global(html[data-theme="light"]) .subtitle :global(a:hover) {
88
+ text-decoration-color: rgba(0, 0, 0, 0.4);
89
+ }
90
+
91
+ @media (max-width: 768px) {
92
+ .title {
93
+ font-size: 36px;
94
+ margin-bottom: 32px;
95
+ letter-spacing: -0.3px;
96
+ }
97
+
98
+ .subtitle {
99
+ font-size: var(--sp-font-body);
100
+ line-height: 1.65;
101
+ }
102
+ }
103
+
104
+ @media (max-width: 480px) {
105
+ .title {
106
+ font-size: 32px;
107
+ margin-bottom: 28px;
108
+ letter-spacing: -0.2px;
109
+ }
110
+
111
+ .subtitle {
112
+ font-size: var(--sp-font-body);
113
+ }
114
+ }
115
+ </style>
116
+