@autumnsgrove/groveengine 0.8.5 → 0.9.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/dist/components/WispPanel.svelte +0 -1
- package/dist/components/admin/GutterManager.svelte +213 -101
- package/dist/components/admin/MarkdownEditor.svelte +6 -3
- package/dist/components/custom/ContentWithGutter.svelte +7 -13
- package/dist/components/custom/GutterItem.svelte +8 -2
- package/dist/components/quota/UpgradePrompt.svelte +1 -0
- package/dist/config/domain-blocklist.d.ts +59 -0
- package/dist/config/domain-blocklist.js +731 -0
- package/dist/config/index.d.ts +3 -1
- package/dist/config/index.js +2 -1
- package/dist/config/offensive-blocklist.d.ts +44 -0
- package/dist/config/offensive-blocklist.js +751 -0
- package/dist/config/terrarium.d.ts +109 -0
- package/dist/config/terrarium.js +125 -0
- package/dist/styles/tokens.css +90 -0
- package/dist/types/dom-to-image-more.d.ts +39 -0
- package/dist/ui/components/chrome/Footer.svelte +137 -0
- package/dist/ui/components/chrome/Footer.svelte.d.ts +11 -0
- package/dist/ui/components/chrome/FooterMinimal.svelte +75 -0
- package/dist/ui/components/chrome/FooterMinimal.svelte.d.ts +10 -0
- package/dist/ui/components/chrome/Header.svelte +113 -0
- package/dist/ui/components/chrome/Header.svelte.d.ts +11 -0
- package/dist/ui/components/chrome/HeaderMinimal.svelte +68 -0
- package/dist/ui/components/chrome/HeaderMinimal.svelte.d.ts +9 -0
- package/dist/ui/components/chrome/MobileMenu.svelte +145 -0
- package/dist/ui/components/chrome/MobileMenu.svelte.d.ts +9 -0
- package/dist/ui/components/chrome/ThemeToggle.svelte +34 -0
- package/dist/ui/components/chrome/ThemeToggle.svelte.d.ts +3 -0
- package/dist/ui/components/chrome/defaults.d.ts +6 -0
- package/dist/ui/components/chrome/defaults.js +65 -0
- package/dist/ui/components/chrome/index.d.ts +13 -0
- package/dist/ui/components/chrome/index.js +14 -0
- package/dist/ui/components/chrome/types.d.ts +19 -0
- package/dist/ui/components/chrome/types.js +8 -0
- package/dist/ui/components/content/RoadmapPreview.svelte +2 -1
- package/dist/ui/components/forms/ContentSearch.svelte +406 -0
- package/dist/ui/components/forms/ContentSearch.svelte.d.ts +71 -0
- package/dist/ui/components/forms/SearchInput.svelte +0 -1
- package/dist/ui/components/forms/filterUtils.d.ts +138 -0
- package/dist/ui/components/forms/filterUtils.js +240 -0
- package/dist/ui/components/forms/index.d.ts +2 -0
- package/dist/ui/components/forms/index.js +5 -1
- package/dist/ui/components/gallery/ImageGallery.svelte +17 -3
- package/dist/ui/components/gallery/Lightbox.svelte +11 -3
- package/dist/ui/components/gallery/ZoomableImage.svelte +13 -2
- package/dist/ui/components/icons/index.d.ts +2 -1
- package/dist/ui/components/icons/index.js +14 -3
- package/dist/ui/components/icons/lucide.d.ts +213 -0
- package/dist/ui/components/icons/lucide.js +224 -0
- package/dist/ui/components/terrarium/AssetPalette.svelte +207 -0
- package/dist/ui/components/terrarium/AssetPalette.svelte.d.ts +7 -0
- package/dist/ui/components/terrarium/Canvas.svelte +231 -0
- package/dist/ui/components/terrarium/Canvas.svelte.d.ts +14 -0
- package/dist/ui/components/terrarium/ExportDialog.svelte +307 -0
- package/dist/ui/components/terrarium/ExportDialog.svelte.d.ts +18 -0
- package/dist/ui/components/terrarium/PaletteItem.svelte +169 -0
- package/dist/ui/components/terrarium/PaletteItem.svelte.d.ts +9 -0
- package/dist/ui/components/terrarium/PlacedAsset.svelte +222 -0
- package/dist/ui/components/terrarium/PlacedAsset.svelte.d.ts +11 -0
- package/dist/ui/components/terrarium/Terrarium.svelte +266 -0
- package/dist/ui/components/terrarium/Terrarium.svelte.d.ts +3 -0
- package/dist/ui/components/terrarium/Toolbar.svelte +299 -0
- package/dist/ui/components/terrarium/Toolbar.svelte.d.ts +24 -0
- package/dist/ui/components/terrarium/index.d.ts +31 -0
- package/dist/ui/components/terrarium/index.js +33 -0
- package/dist/ui/components/terrarium/terrariumState.svelte.d.ts +45 -0
- package/dist/ui/components/terrarium/terrariumState.svelte.js +291 -0
- package/dist/ui/components/terrarium/types.d.ts +139 -0
- package/dist/ui/components/terrarium/types.js +43 -0
- package/dist/ui/components/terrarium/utils/export.d.ts +48 -0
- package/dist/ui/components/terrarium/utils/export.js +148 -0
- package/dist/ui/components/typography/index.d.ts +0 -10
- package/dist/ui/components/typography/index.js +1 -12
- package/dist/ui/components/ui/CollapsibleSection.svelte +12 -0
- package/dist/ui/components/ui/GlassConfirmDialog.svelte +9 -0
- package/dist/ui/components/ui/GlassOverlay.svelte +2 -1
- package/dist/ui/components/ui/Input.svelte +9 -1
- package/dist/ui/components/ui/Input.svelte.d.ts +2 -0
- package/dist/ui/components/ui/Textarea.svelte +9 -1
- package/dist/ui/components/ui/Textarea.svelte.d.ts +2 -0
- package/dist/ui/stores/index.d.ts +6 -0
- package/dist/ui/stores/index.js +6 -0
- package/dist/ui/stores/season.d.ts +14 -0
- package/dist/ui/stores/season.js +65 -0
- package/dist/ui/tokens/fonts.d.ts +1 -1
- package/dist/ui/tokens/fonts.js +0 -126
- package/package.json +46 -22
- package/static/fonts/alagard.ttf +0 -0
- package/LICENSE +0 -378
- package/dist/ui/components/typography/BodoniModa.svelte +0 -17
- package/dist/ui/components/typography/BodoniModa.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Cormorant.svelte +0 -17
- package/dist/ui/components/typography/Cormorant.svelte.d.ts +0 -10
- package/dist/ui/components/typography/EBGaramond.svelte +0 -17
- package/dist/ui/components/typography/EBGaramond.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Fraunces.svelte +0 -17
- package/dist/ui/components/typography/Fraunces.svelte.d.ts +0 -10
- package/dist/ui/components/typography/InstrumentSans.svelte +0 -17
- package/dist/ui/components/typography/InstrumentSans.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Lora.svelte +0 -17
- package/dist/ui/components/typography/Lora.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Luciole.svelte +0 -17
- package/dist/ui/components/typography/Luciole.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Manrope.svelte +0 -17
- package/dist/ui/components/typography/Manrope.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Merriweather.svelte +0 -17
- package/dist/ui/components/typography/Merriweather.svelte.d.ts +0 -10
- package/dist/ui/components/typography/Nunito.svelte +0 -17
- package/dist/ui/components/typography/Nunito.svelte.d.ts +0 -10
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ContentSearch Component
|
|
3
|
+
|
|
4
|
+
A reusable, accessible search component with debouncing, URL synchronization, and custom filtering.
|
|
5
|
+
Part of the Grove UI design system - "a place to Be"
|
|
6
|
+
|
|
7
|
+
@example Basic usage
|
|
8
|
+
```svelte
|
|
9
|
+
<ContentSearch
|
|
10
|
+
items={posts}
|
|
11
|
+
filterFn={(post, query) => post.title.toLowerCase().includes(query.toLowerCase())}
|
|
12
|
+
bind:searchQuery
|
|
13
|
+
placeholder="Search posts..."
|
|
14
|
+
/>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
@example With URL sync and custom filters
|
|
18
|
+
```svelte
|
|
19
|
+
<ContentSearch
|
|
20
|
+
items={docs}
|
|
21
|
+
filterFn={filterDoc}
|
|
22
|
+
bind:searchQuery
|
|
23
|
+
syncWithUrl={true}
|
|
24
|
+
onSearchChange={(query, results) => filteredResults = results}
|
|
25
|
+
>
|
|
26
|
+
{#snippet children()}
|
|
27
|
+
<div class="custom-filters">
|
|
28
|
+
Add tag filters, date filters, etc. here
|
|
29
|
+
</div>
|
|
30
|
+
{/snippet}
|
|
31
|
+
</ContentSearch>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
@example With pre-computed lowercase fields (performance optimization)
|
|
35
|
+
```svelte
|
|
36
|
+
<script>
|
|
37
|
+
// Pre-compute lowercase fields for better performance
|
|
38
|
+
let postsWithLowercase = $derived.by(() => {
|
|
39
|
+
return posts.map(post => ({
|
|
40
|
+
...post,
|
|
41
|
+
titleLower: post.title.toLowerCase(),
|
|
42
|
+
tagsLower: post.tags.map(t => t.toLowerCase())
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function filterPost(post, query) {
|
|
47
|
+
const q = query.toLowerCase();
|
|
48
|
+
return post.titleLower.includes(q) || post.tagsLower.some(tag => tag.includes(q));
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<ContentSearch items={postsWithLowercase} filterFn={filterPost} />
|
|
53
|
+
```
|
|
54
|
+
-->
|
|
55
|
+
<script lang="ts" generics="T extends Record<string, unknown>">
|
|
56
|
+
import { page } from '$app/stores';
|
|
57
|
+
import { goto } from '$app/navigation';
|
|
58
|
+
import type { Snippet } from 'svelte';
|
|
59
|
+
|
|
60
|
+
interface Props {
|
|
61
|
+
/** Array of items to search through */
|
|
62
|
+
items: T[];
|
|
63
|
+
/** Filter function that determines if an item matches the search query */
|
|
64
|
+
filterFn: (item: T, query: string) => boolean;
|
|
65
|
+
/** Current search query (bindable) */
|
|
66
|
+
searchQuery?: string;
|
|
67
|
+
/** Placeholder text for search input */
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Type of results for screen reader announcements (singular form)
|
|
71
|
+
* @example "post" → "Found 1 post" / "Found 5 posts"
|
|
72
|
+
* @example "document" → "Found 1 document" / "Found 5 documents"
|
|
73
|
+
* @example "match" → Use with resultsTypePlural for "Found 1 match" / "Found 5 matches"
|
|
74
|
+
*/
|
|
75
|
+
resultsType?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Plural form of resultsType (defaults to resultsType + 's')
|
|
78
|
+
*
|
|
79
|
+
* **Use this for irregular plurals:**
|
|
80
|
+
* @example resultsType="match" resultsTypePlural="matches"
|
|
81
|
+
* @example resultsType="person" resultsTypePlural="people"
|
|
82
|
+
* @example resultsType="child" resultsTypePlural="children"
|
|
83
|
+
* @example resultsType="datum" resultsTypePlural="data"
|
|
84
|
+
*
|
|
85
|
+
* Screen reader will announce: "Found 1 match" / "Found 5 matches"
|
|
86
|
+
*/
|
|
87
|
+
resultsTypePlural?: string;
|
|
88
|
+
/** Whether to sync search query with URL params */
|
|
89
|
+
syncWithUrl?: boolean;
|
|
90
|
+
/** URL parameter name for search query */
|
|
91
|
+
queryParam?: string;
|
|
92
|
+
/** Debounce delay in milliseconds */
|
|
93
|
+
debounceDelay?: number;
|
|
94
|
+
/** Whether to show search icon */
|
|
95
|
+
showIcon?: boolean;
|
|
96
|
+
/** Whether to show clear button when there's a query */
|
|
97
|
+
showClearButton?: boolean;
|
|
98
|
+
/** CSS class for the search input wrapper */
|
|
99
|
+
wrapperClass?: string;
|
|
100
|
+
/** CSS class for the search input */
|
|
101
|
+
inputClass?: string;
|
|
102
|
+
/** Callback when search query changes */
|
|
103
|
+
onSearchChange?: (query: string, results: T[]) => void;
|
|
104
|
+
/** Optional snippet for filters */
|
|
105
|
+
children?: Snippet;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let {
|
|
109
|
+
items = [],
|
|
110
|
+
filterFn,
|
|
111
|
+
searchQuery = $bindable(''),
|
|
112
|
+
placeholder = 'Search...',
|
|
113
|
+
resultsType = 'result',
|
|
114
|
+
resultsTypePlural,
|
|
115
|
+
syncWithUrl = false,
|
|
116
|
+
queryParam = 'q',
|
|
117
|
+
debounceDelay = 250,
|
|
118
|
+
showIcon = true,
|
|
119
|
+
showClearButton = true,
|
|
120
|
+
wrapperClass = '',
|
|
121
|
+
inputClass = '',
|
|
122
|
+
onSearchChange,
|
|
123
|
+
children
|
|
124
|
+
}: Props = $props();
|
|
125
|
+
|
|
126
|
+
// Generate unique ID for accessibility
|
|
127
|
+
const searchId = `content-search-${Math.random().toString(36).substring(2, 9)}`;
|
|
128
|
+
const clearButtonId = `${searchId}-clear`;
|
|
129
|
+
const resultsId = `${searchId}-results`;
|
|
130
|
+
|
|
131
|
+
// Initialize from URL if syncing
|
|
132
|
+
$effect(() => {
|
|
133
|
+
if (syncWithUrl && typeof window !== 'undefined') {
|
|
134
|
+
const urlQuery = $page.url.searchParams.get(queryParam);
|
|
135
|
+
if (urlQuery && urlQuery !== searchQuery) {
|
|
136
|
+
searchQuery = urlQuery;
|
|
137
|
+
debouncedQuery = urlQuery;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Debounced search query for performance
|
|
143
|
+
let debouncedQuery = $state(searchQuery);
|
|
144
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = $state(null);
|
|
145
|
+
|
|
146
|
+
// Cleanup timer on component destruction to prevent memory leaks
|
|
147
|
+
// This is separate from the handleInput cleanup because:
|
|
148
|
+
// - handleInput clears the timer when user types (normal debounce behavior)
|
|
149
|
+
// - This cleanup runs when component is destroyed (prevents timer firing after unmount)
|
|
150
|
+
$effect(() => {
|
|
151
|
+
return () => {
|
|
152
|
+
if (debounceTimer) {
|
|
153
|
+
clearTimeout(debounceTimer);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function handleInput(event: Event) {
|
|
159
|
+
const target = event.target as HTMLInputElement;
|
|
160
|
+
searchQuery = target.value;
|
|
161
|
+
|
|
162
|
+
// Clear existing timer to restart debounce window
|
|
163
|
+
if (debounceTimer) {
|
|
164
|
+
clearTimeout(debounceTimer);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Set new timer for debounced update
|
|
168
|
+
debounceTimer = setTimeout(() => {
|
|
169
|
+
debouncedQuery = searchQuery;
|
|
170
|
+
if (syncWithUrl) {
|
|
171
|
+
updateUrl();
|
|
172
|
+
}
|
|
173
|
+
}, debounceDelay);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Filter items based on debounced search query
|
|
177
|
+
let filteredItems = $derived.by(() => {
|
|
178
|
+
if (!debouncedQuery.trim()) {
|
|
179
|
+
return items;
|
|
180
|
+
}
|
|
181
|
+
return items.filter(item => filterFn(item, debouncedQuery.trim()));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Call onSearchChange when results change
|
|
185
|
+
$effect(() => {
|
|
186
|
+
if (onSearchChange) {
|
|
187
|
+
onSearchChange(debouncedQuery, filteredItems);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Update URL when search query changes (optimized to avoid unnecessary navigation)
|
|
192
|
+
function updateUrl() {
|
|
193
|
+
if (!syncWithUrl) return;
|
|
194
|
+
|
|
195
|
+
const currentQuery = $page.url.searchParams.get(queryParam) || '';
|
|
196
|
+
const newQuery = searchQuery.trim();
|
|
197
|
+
|
|
198
|
+
// Skip navigation if query hasn't actually changed
|
|
199
|
+
if (currentQuery === newQuery) return;
|
|
200
|
+
|
|
201
|
+
const params = new URLSearchParams($page.url.searchParams);
|
|
202
|
+
if (newQuery) {
|
|
203
|
+
params.set(queryParam, newQuery);
|
|
204
|
+
} else {
|
|
205
|
+
params.delete(queryParam);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const newUrl = params.toString() ? `?${params.toString()}` : $page.url.pathname;
|
|
209
|
+
goto(newUrl, { replaceState: true, keepFocus: true });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function clearSearch() {
|
|
213
|
+
searchQuery = '';
|
|
214
|
+
debouncedQuery = '';
|
|
215
|
+
if (debounceTimer) {
|
|
216
|
+
clearTimeout(debounceTimer);
|
|
217
|
+
debounceTimer = null;
|
|
218
|
+
}
|
|
219
|
+
if (syncWithUrl) {
|
|
220
|
+
updateUrl();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<div class="content-search-wrapper {wrapperClass}" role="search">
|
|
226
|
+
<div class="content-search-input-container">
|
|
227
|
+
{#if showIcon}
|
|
228
|
+
<svg
|
|
229
|
+
class="content-search-icon"
|
|
230
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
231
|
+
width="20"
|
|
232
|
+
height="20"
|
|
233
|
+
viewBox="0 0 24 24"
|
|
234
|
+
fill="none"
|
|
235
|
+
stroke="currentColor"
|
|
236
|
+
stroke-width="2"
|
|
237
|
+
stroke-linecap="round"
|
|
238
|
+
stroke-linejoin="round"
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
>
|
|
241
|
+
<circle cx="11" cy="11" r="8"></circle>
|
|
242
|
+
<path d="m21 21-4.3-4.3"></path>
|
|
243
|
+
</svg>
|
|
244
|
+
{/if}
|
|
245
|
+
<input
|
|
246
|
+
type="search"
|
|
247
|
+
id={searchId}
|
|
248
|
+
aria-label={placeholder}
|
|
249
|
+
aria-describedby={showClearButton && searchQuery ? clearButtonId : undefined}
|
|
250
|
+
{placeholder}
|
|
251
|
+
value={searchQuery}
|
|
252
|
+
oninput={handleInput}
|
|
253
|
+
class="content-search-input {inputClass}"
|
|
254
|
+
/>
|
|
255
|
+
{#if showClearButton && searchQuery}
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
id={clearButtonId}
|
|
259
|
+
onclick={clearSearch}
|
|
260
|
+
class="content-search-clear"
|
|
261
|
+
aria-label="Clear search query"
|
|
262
|
+
>
|
|
263
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
264
|
+
<path d="M18 6 6 18"></path>
|
|
265
|
+
<path d="m6 6 12 12"></path>
|
|
266
|
+
</svg>
|
|
267
|
+
</button>
|
|
268
|
+
{/if}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{#if children}
|
|
272
|
+
<div class="content-search-filters">
|
|
273
|
+
{@render children()}
|
|
274
|
+
</div>
|
|
275
|
+
{/if}
|
|
276
|
+
|
|
277
|
+
<!-- Screen reader announcement for search results -->
|
|
278
|
+
<div id={resultsId} role="status" aria-live="polite" aria-atomic="true" aria-label="Search results" class="sr-only">
|
|
279
|
+
{#if debouncedQuery}
|
|
280
|
+
Found {filteredItems.length} {filteredItems.length === 1 ? resultsType : (resultsTypePlural || resultsType + 's')} matching "{debouncedQuery}"
|
|
281
|
+
{/if}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<style>
|
|
286
|
+
.content-search-wrapper {
|
|
287
|
+
width: 100%;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.content-search-input-container {
|
|
291
|
+
position: relative;
|
|
292
|
+
display: flex;
|
|
293
|
+
align-items: center;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.content-search-icon {
|
|
297
|
+
position: absolute;
|
|
298
|
+
left: 1rem;
|
|
299
|
+
color: var(--light-text-light, #999);
|
|
300
|
+
pointer-events: none;
|
|
301
|
+
transition: color 0.3s ease;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.content-search-input {
|
|
305
|
+
width: 100%;
|
|
306
|
+
padding: 0.75rem 1rem;
|
|
307
|
+
padding-left: 3rem;
|
|
308
|
+
font-size: 1rem;
|
|
309
|
+
border: 2px solid var(--light-border-primary, #e5e7eb);
|
|
310
|
+
border-radius: 0.75rem;
|
|
311
|
+
background: white;
|
|
312
|
+
color: var(--light-text-primary, #1f2937);
|
|
313
|
+
transition: border-color 0.2s ease, background-color 0.3s ease, color 0.3s ease;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.content-search-input:focus {
|
|
317
|
+
outline: none;
|
|
318
|
+
border-color: var(--accent, #2c5f2d);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.content-search-input::-moz-placeholder {
|
|
322
|
+
color: var(--light-text-muted, #9ca3af);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.content-search-input::placeholder {
|
|
326
|
+
color: var(--light-text-muted, #9ca3af);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.content-search-clear {
|
|
330
|
+
position: absolute;
|
|
331
|
+
right: 0.75rem;
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
justify-content: center;
|
|
335
|
+
padding: 0.5rem;
|
|
336
|
+
background: transparent;
|
|
337
|
+
border: none;
|
|
338
|
+
cursor: pointer;
|
|
339
|
+
color: var(--light-text-light, #999);
|
|
340
|
+
transition: color 0.2s ease;
|
|
341
|
+
border-radius: 0.375rem;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.content-search-clear:hover {
|
|
345
|
+
color: var(--light-text-primary, #1f2937);
|
|
346
|
+
background-color: var(--light-bg-secondary, #f3f4f6);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.content-search-clear:focus {
|
|
350
|
+
outline: 2px solid var(--accent, #2c5f2d);
|
|
351
|
+
outline-offset: 2px;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.content-search-filters {
|
|
355
|
+
margin-top: 1rem;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Visually hidden but accessible to screen readers */
|
|
359
|
+
.sr-only {
|
|
360
|
+
position: absolute;
|
|
361
|
+
width: 1px;
|
|
362
|
+
height: 1px;
|
|
363
|
+
padding: 0;
|
|
364
|
+
margin: -1px;
|
|
365
|
+
overflow: hidden;
|
|
366
|
+
clip: rect(0, 0, 0, 0);
|
|
367
|
+
white-space: nowrap;
|
|
368
|
+
border-width: 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* Dark mode support */
|
|
372
|
+
:global(.dark) .content-search-icon {
|
|
373
|
+
color: var(--dark-text-light, #6b7280);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
:global(.dark) .content-search-input {
|
|
377
|
+
background: var(--dark-bg-secondary, #1f2937);
|
|
378
|
+
border-color: var(--dark-border-primary, #374151);
|
|
379
|
+
color: var(--dark-text-primary, #f9fafb);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
:global(.dark) .content-search-input:focus {
|
|
383
|
+
border-color: var(--accent, #4ade80);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
:global(.dark) .content-search-input::-moz-placeholder {
|
|
387
|
+
color: var(--dark-text-muted, #6b7280);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
:global(.dark) .content-search-input::placeholder {
|
|
391
|
+
color: var(--dark-text-muted, #6b7280);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
:global(.dark) .content-search-clear {
|
|
395
|
+
color: var(--dark-text-light, #6b7280);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
:global(.dark) .content-search-clear:hover {
|
|
399
|
+
color: var(--dark-text-primary, #f9fafb);
|
|
400
|
+
background-color: var(--dark-bg-tertiary, #111827);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
:global(.dark) .content-search-clear:focus {
|
|
404
|
+
outline-color: var(--accent, #4ade80);
|
|
405
|
+
}
|
|
406
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
declare function $$render<T extends Record<string, unknown>>(): {
|
|
3
|
+
props: {
|
|
4
|
+
/** Array of items to search through */
|
|
5
|
+
items: T[];
|
|
6
|
+
/** Filter function that determines if an item matches the search query */
|
|
7
|
+
filterFn: (item: T, query: string) => boolean;
|
|
8
|
+
/** Current search query (bindable) */
|
|
9
|
+
searchQuery?: string;
|
|
10
|
+
/** Placeholder text for search input */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Type of results for screen reader announcements (singular form)
|
|
14
|
+
* @example "post" → "Found 1 post" / "Found 5 posts"
|
|
15
|
+
* @example "document" → "Found 1 document" / "Found 5 documents"
|
|
16
|
+
* @example "match" → Use with resultsTypePlural for "Found 1 match" / "Found 5 matches"
|
|
17
|
+
*/
|
|
18
|
+
resultsType?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Plural form of resultsType (defaults to resultsType + 's')
|
|
21
|
+
*
|
|
22
|
+
* **Use this for irregular plurals:**
|
|
23
|
+
* @example resultsType="match" resultsTypePlural="matches"
|
|
24
|
+
* @example resultsType="person" resultsTypePlural="people"
|
|
25
|
+
* @example resultsType="child" resultsTypePlural="children"
|
|
26
|
+
* @example resultsType="datum" resultsTypePlural="data"
|
|
27
|
+
*
|
|
28
|
+
* Screen reader will announce: "Found 1 match" / "Found 5 matches"
|
|
29
|
+
*/
|
|
30
|
+
resultsTypePlural?: string;
|
|
31
|
+
/** Whether to sync search query with URL params */
|
|
32
|
+
syncWithUrl?: boolean;
|
|
33
|
+
/** URL parameter name for search query */
|
|
34
|
+
queryParam?: string;
|
|
35
|
+
/** Debounce delay in milliseconds */
|
|
36
|
+
debounceDelay?: number;
|
|
37
|
+
/** Whether to show search icon */
|
|
38
|
+
showIcon?: boolean;
|
|
39
|
+
/** Whether to show clear button when there's a query */
|
|
40
|
+
showClearButton?: boolean;
|
|
41
|
+
/** CSS class for the search input wrapper */
|
|
42
|
+
wrapperClass?: string;
|
|
43
|
+
/** CSS class for the search input */
|
|
44
|
+
inputClass?: string;
|
|
45
|
+
/** Callback when search query changes */
|
|
46
|
+
onSearchChange?: (query: string, results: T[]) => void;
|
|
47
|
+
/** Optional snippet for filters */
|
|
48
|
+
children?: Snippet;
|
|
49
|
+
};
|
|
50
|
+
exports: {};
|
|
51
|
+
bindings: "searchQuery";
|
|
52
|
+
slots: {};
|
|
53
|
+
events: {};
|
|
54
|
+
};
|
|
55
|
+
declare class __sveltets_Render<T extends Record<string, unknown>> {
|
|
56
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
57
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
58
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
59
|
+
bindings(): "searchQuery";
|
|
60
|
+
exports(): {};
|
|
61
|
+
}
|
|
62
|
+
interface $$IsomorphicComponent {
|
|
63
|
+
new <T extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
64
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
65
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
66
|
+
<T extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
67
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
68
|
+
}
|
|
69
|
+
declare const ContentSearch: $$IsomorphicComponent;
|
|
70
|
+
type ContentSearch<T extends Record<string, unknown>> = InstanceType<typeof ContentSearch<T>>;
|
|
71
|
+
export default ContentSearch;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common filter utilities for use with ContentSearch component
|
|
3
|
+
* Part of the Grove UI design system
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { createTextFilter, createMultiFieldFilter } from '@autumnsgrove/groveengine';
|
|
8
|
+
*
|
|
9
|
+
* const filterPost = createTextFilter(['title', 'description']);
|
|
10
|
+
* <ContentSearch items={posts} filterFn={filterPost} />
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Normalize text for searching (lowercase, trim)
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeSearchText(text: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Check if normalized text includes a normalized query
|
|
19
|
+
* Note: Both inputs should already be normalized via normalizeSearchText()
|
|
20
|
+
*
|
|
21
|
+
* @param normalizedText - Already normalized text to search in
|
|
22
|
+
* @param normalizedQuery - Already normalized query to search for
|
|
23
|
+
*/
|
|
24
|
+
export declare function includesNormalized(normalizedText: string, normalizedQuery: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Check if text includes a search query (case-insensitive)
|
|
27
|
+
* This is a convenience function that normalizes both inputs
|
|
28
|
+
*
|
|
29
|
+
* @param text - Text to search in (will be normalized)
|
|
30
|
+
* @param query - Query to search for (will be normalized)
|
|
31
|
+
*/
|
|
32
|
+
export declare function textIncludes(text: string, query: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Create a filter function that searches multiple text fields
|
|
35
|
+
*
|
|
36
|
+
* @param fields - Array of field names to search
|
|
37
|
+
* @returns Filter function for use with ContentSearch
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const filterPost = createTextFilter(['title', 'description', 'author']);
|
|
42
|
+
* <ContentSearch items={posts} filterFn={filterPost} />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function createTextFilter<T extends Record<string, unknown>>(fields: (keyof T)[]): (item: T, query: string) => boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Create a filter function that searches text fields and array fields (like tags)
|
|
48
|
+
*
|
|
49
|
+
* @param textFields - Array of text field names to search
|
|
50
|
+
* @param arrayFields - Array of array field names to search (e.g., tags)
|
|
51
|
+
* @returns Filter function for use with ContentSearch
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const filterPost = createMultiFieldFilter(['title', 'description'], ['tags', 'categories']);
|
|
56
|
+
* <ContentSearch items={posts} filterFn={filterPost} />
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export declare function createMultiFieldFilter<T extends Record<string, unknown>>(textFields: (keyof T)[], arrayFields?: (keyof T)[]): (item: T, query: string) => boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Pre-compute lowercase versions of searchable fields for performance
|
|
62
|
+
*
|
|
63
|
+
* **When to use this optimization:**
|
|
64
|
+
* - **Large datasets (100+ items)**: Repeated toLowerCase() calls during filtering become expensive
|
|
65
|
+
* - **Frequent searches**: Users actively searching/filtering the same dataset multiple times
|
|
66
|
+
* - **Multiple searchable fields**: More fields = more normalization = more performance gain
|
|
67
|
+
* - **Real-time filtering**: When debouncing isn't enough and you need instant results
|
|
68
|
+
*
|
|
69
|
+
* **When NOT to use:**
|
|
70
|
+
* - Small datasets (<50 items): Minimal performance benefit, adds memory overhead
|
|
71
|
+
* - Static lists that don't change: Consider precomputing at build time instead
|
|
72
|
+
* - Infrequent searches: The setup cost may outweigh the benefit
|
|
73
|
+
*
|
|
74
|
+
* @param items - Array of items to optimize
|
|
75
|
+
* @param fields - Fields to pre-compute lowercase versions of
|
|
76
|
+
* @returns Array of items with lowercase fields added (suffixed with 'Lower')
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* // In your Svelte component with large dataset (100+ posts)
|
|
81
|
+
* let postsWithLowercase = $derived.by(() => {
|
|
82
|
+
* return precomputeLowercaseFields(posts, ['title', 'description', 'tags']);
|
|
83
|
+
* });
|
|
84
|
+
* // Creates: { ...post, titleLower: '...', descriptionLower: '...', tagsLower: [...] }
|
|
85
|
+
*
|
|
86
|
+
* function filterPost(post, query) {
|
|
87
|
+
* const q = query.toLowerCase();
|
|
88
|
+
* return post.titleLower.includes(q) ||
|
|
89
|
+
* post.descriptionLower.includes(q) ||
|
|
90
|
+
* post.tagsLower.some(tag => tag.includes(q));
|
|
91
|
+
* }
|
|
92
|
+
*
|
|
93
|
+
* <ContentSearch items={postsWithLowercase} filterFn={filterPost} />
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export declare function precomputeLowercaseFields<T extends Record<string, unknown>>(items: T[], fields: (keyof T)[]): (T & Record<string, unknown>)[];
|
|
97
|
+
/**
|
|
98
|
+
* Create a fuzzy filter that matches partial words
|
|
99
|
+
*
|
|
100
|
+
* @param fields - Array of field names to search
|
|
101
|
+
* @param minMatchLength - Minimum length of query before fuzzy matching (default: 2)
|
|
102
|
+
* @returns Filter function for use with ContentSearch
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* const filterPost = createFuzzyFilter(['title', 'description']);
|
|
107
|
+
* // Matches: "jav" in "JavaScript", "scr" in "TypeScript"
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export declare function createFuzzyFilter<T extends Record<string, unknown>>(fields: (keyof T)[], minMatchLength?: number): (item: T, query: string) => boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Combine multiple filter functions with AND logic
|
|
113
|
+
*
|
|
114
|
+
* @param filters - Array of filter functions to combine
|
|
115
|
+
* @returns Combined filter function
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const hasTag = (item, tag) => item.tags.includes(tag);
|
|
120
|
+
* const matchesText = createTextFilter(['title']);
|
|
121
|
+
* const combinedFilter = combineFilters([matchesText, (item) => hasTag(item, 'featured')]);
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export declare function combineFilters<T>(filters: Array<(item: T, query: string) => boolean>): (item: T, query: string) => boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Create a date range filter
|
|
127
|
+
*
|
|
128
|
+
* @param dateField - Field name containing the date
|
|
129
|
+
* @param startDate - Start of date range (optional)
|
|
130
|
+
* @param endDate - End of date range (optional)
|
|
131
|
+
* @returns Filter function for use with ContentSearch
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* const recentPostsFilter = createDateFilter('publishedAt', new Date('2024-01-01'));
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export declare function createDateFilter<T extends Record<string, unknown>>(dateField: keyof T, startDate?: Date, endDate?: Date): (item: T, query: string) => boolean;
|