svelte-theme-picker 1.0.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,514 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import type { Theme, ThemePickerConfig } from './types.js';
4
+ import { defaultThemes, DEFAULT_THEME_ID } from './themes.js';
5
+ import { createThemeStore, applyTheme, type ThemeStore } from './store.js';
6
+
7
+ interface Props {
8
+ /** Configuration options */
9
+ config?: ThemePickerConfig;
10
+ /** Custom theme store (if not provided, creates one internally) */
11
+ store?: ThemeStore;
12
+ /** Position of the trigger button */
13
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
14
+ /** Whether to show the trigger button (false = inline mode) */
15
+ showTrigger?: boolean;
16
+ /** Layout direction for inline mode */
17
+ layout?: 'vertical' | 'horizontal';
18
+ /** Callback when theme changes */
19
+ onThemeChange?: (themeId: string, theme: Theme) => void;
20
+ }
21
+
22
+ const {
23
+ config = {},
24
+ store = undefined,
25
+ position = 'bottom-right',
26
+ showTrigger = true,
27
+ layout = 'vertical',
28
+ onThemeChange = undefined,
29
+ }: Props = $props();
30
+
31
+ /*
32
+ * These values are intentionally captured once from initial props.
33
+ * The theme picker doesn't support changing config/store after mount.
34
+ * This is by design - create a new component instance to change config.
35
+ */
36
+ // svelte-ignore state_referenced_locally
37
+ const themeStore: ThemeStore = store ?? createThemeStore(config);
38
+ // svelte-ignore state_referenced_locally
39
+ const themes: Record<string, Theme> = config.themes ?? defaultThemes;
40
+ // svelte-ignore state_referenced_locally
41
+ const cssVarPrefix: string = config.cssVarPrefix ?? '';
42
+
43
+ let isOpen = $state(false);
44
+ // svelte-ignore state_referenced_locally
45
+ let currentThemeId = $state(config.defaultTheme ?? DEFAULT_THEME_ID);
46
+
47
+ onMount(() => {
48
+ const unsubscribe = themeStore.subscribe((themeId) => {
49
+ currentThemeId = themeId;
50
+ const theme = themes[themeId];
51
+ if (theme) {
52
+ applyTheme(theme, cssVarPrefix);
53
+ onThemeChange?.(themeId, theme);
54
+ }
55
+ });
56
+
57
+ // Apply initial theme
58
+ const initialTheme = themes[currentThemeId];
59
+ if (initialTheme) {
60
+ applyTheme(initialTheme, cssVarPrefix);
61
+ }
62
+
63
+ return unsubscribe;
64
+ });
65
+
66
+ function selectTheme(themeId: string) {
67
+ themeStore.setTheme(themeId);
68
+ isOpen = false;
69
+ }
70
+
71
+ function togglePanel() {
72
+ isOpen = !isOpen;
73
+ }
74
+
75
+ function closePanel() {
76
+ isOpen = false;
77
+ }
78
+
79
+ function handleKeydown(e: KeyboardEvent) {
80
+ // Only process Escape when panel is open to minimize event handler overhead
81
+ if (isOpen && e.key === 'Escape') {
82
+ isOpen = false;
83
+ }
84
+ }
85
+ </script>
86
+
87
+ <svelte:window onkeydown={handleKeydown} />
88
+
89
+ <div
90
+ class="stp-theme-picker"
91
+ class:stp-bottom-right={position === 'bottom-right'}
92
+ class:stp-bottom-left={position === 'bottom-left'}
93
+ class:stp-top-right={position === 'top-right'}
94
+ class:stp-top-left={position === 'top-left'}
95
+ class:stp-inline={!showTrigger}
96
+ >
97
+ {#if showTrigger}
98
+ <button
99
+ type="button"
100
+ class="stp-trigger"
101
+ onclick={togglePanel}
102
+ title="Switch theme"
103
+ aria-label="Open theme switcher"
104
+ aria-expanded={isOpen}
105
+ aria-haspopup="dialog"
106
+ >
107
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20" aria-hidden="true">
108
+ <circle cx="12" cy="12" r="5"/>
109
+ <line x1="12" y1="1" x2="12" y2="3"/>
110
+ <line x1="12" y1="21" x2="12" y2="23"/>
111
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
112
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
113
+ <line x1="1" y1="12" x2="3" y2="12"/>
114
+ <line x1="21" y1="12" x2="23" y2="12"/>
115
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
116
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
117
+ </svg>
118
+ </button>
119
+ {/if}
120
+
121
+ {#if isOpen || !showTrigger}
122
+ {#if showTrigger}
123
+ <!-- Backdrop for closing panel - intentionally non-interactive for screen readers -->
124
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
125
+ <div class="stp-backdrop" onclick={closePanel} aria-hidden="true"></div>
126
+ {/if}
127
+ <div class="stp-panel" class:stp-panel-inline={!showTrigger} class:stp-horizontal={layout === 'horizontal'}>
128
+ <div class="stp-header">
129
+ <h3 class="stp-title">Theme</h3>
130
+ {#if showTrigger}
131
+ <button type="button" class="stp-close" onclick={closePanel} aria-label="Close theme picker">
132
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18" aria-hidden="true">
133
+ <line x1="18" y1="6" x2="6" y2="18"/>
134
+ <line x1="6" y1="6" x2="18" y2="18"/>
135
+ </svg>
136
+ </button>
137
+ {/if}
138
+ </div>
139
+ <div class="stp-list" role="listbox" aria-label="Available themes">
140
+ {#each Object.entries(themes) as [id, theme]}
141
+ <button
142
+ type="button"
143
+ class="stp-option"
144
+ class:stp-active={currentThemeId === id}
145
+ onclick={() => selectTheme(id)}
146
+ role="option"
147
+ aria-selected={currentThemeId === id}
148
+ >
149
+ <div class="stp-preview" aria-hidden="true">
150
+ <div class="stp-colors">
151
+ <span style="background: {theme.colors.primary1}"></span>
152
+ <span style="background: {theme.colors.primary3}"></span>
153
+ <span style="background: {theme.colors.primary5}"></span>
154
+ <span style="background: {theme.colors.accent1}"></span>
155
+ </div>
156
+ </div>
157
+ <div class="stp-info">
158
+ <span class="stp-name">{theme.name}</span>
159
+ <span class="stp-desc">{theme.description}</span>
160
+ </div>
161
+ {#if currentThemeId === id}
162
+ <svg class="stp-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18" aria-hidden="true">
163
+ <polyline points="20 6 9 17 4 12"/>
164
+ </svg>
165
+ {/if}
166
+ </button>
167
+ {/each}
168
+ </div>
169
+ </div>
170
+ {/if}
171
+ </div>
172
+
173
+ <style>
174
+ .stp-theme-picker {
175
+ --stp-bg: #2a2a4a;
176
+ --stp-bg-hover: #3d3d6b;
177
+ --stp-bg-active: rgba(168, 85, 247, 0.15);
178
+ --stp-border: rgba(255, 255, 255, 0.1);
179
+ --stp-text: #e8e0f0;
180
+ --stp-text-muted: #9090b0;
181
+ --stp-accent: #a855f7;
182
+ --stp-accent-glow: rgba(168, 85, 247, 0.3);
183
+ --stp-radius: 12px;
184
+ --stp-radius-sm: 8px;
185
+ --stp-space: 12px;
186
+ --stp-space-sm: 8px;
187
+ --stp-transition: 0.2s ease;
188
+
189
+ /* Performance: Use CSS containment for better rendering performance */
190
+ contain: layout style;
191
+ position: fixed;
192
+ z-index: 1000;
193
+ }
194
+
195
+ .stp-inline {
196
+ position: relative;
197
+ }
198
+
199
+ .stp-bottom-right {
200
+ bottom: 24px;
201
+ right: 24px;
202
+ }
203
+
204
+ .stp-bottom-left {
205
+ bottom: 24px;
206
+ left: 24px;
207
+ }
208
+
209
+ .stp-top-right {
210
+ top: 24px;
211
+ right: 24px;
212
+ }
213
+
214
+ .stp-top-left {
215
+ top: 24px;
216
+ left: 24px;
217
+ }
218
+
219
+ .stp-trigger {
220
+ width: 48px;
221
+ height: 48px;
222
+ border-radius: 50%;
223
+ border: none;
224
+ background: linear-gradient(135deg, #c9a0dc, #a855f7);
225
+ color: #1a1a2e;
226
+ cursor: pointer;
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ box-shadow:
231
+ 0 4px 20px var(--stp-accent-glow),
232
+ 0 0 40px rgba(168, 85, 247, 0.1);
233
+ transition: transform var(--stp-transition), box-shadow var(--stp-transition);
234
+ /* Performance: Hint browser to prepare GPU layer for animations */
235
+ will-change: transform;
236
+ }
237
+
238
+ .stp-trigger:hover {
239
+ transform: scale(1.1);
240
+ box-shadow:
241
+ 0 8px 30px var(--stp-accent-glow),
242
+ 0 0 60px rgba(168, 85, 247, 0.2);
243
+ }
244
+
245
+ .stp-backdrop {
246
+ position: fixed;
247
+ inset: 0;
248
+ z-index: -1;
249
+ }
250
+
251
+ .stp-panel {
252
+ position: absolute;
253
+ width: 320px;
254
+ background: linear-gradient(135deg, rgba(42, 42, 74, 0.95), rgba(61, 61, 107, 0.9));
255
+ backdrop-filter: blur(16px);
256
+ -webkit-backdrop-filter: blur(16px); /* Safari support */
257
+ border-radius: var(--stp-radius);
258
+ border: 1px solid var(--stp-border);
259
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
260
+ overflow: hidden;
261
+ animation: stp-slideIn 0.2s ease-out;
262
+ /* Performance: Promote to GPU layer for backdrop-filter */
263
+ transform: translateZ(0);
264
+ }
265
+
266
+ .stp-bottom-right .stp-panel,
267
+ .stp-bottom-left .stp-panel {
268
+ bottom: 60px;
269
+ }
270
+
271
+ .stp-bottom-right .stp-panel {
272
+ right: 0;
273
+ }
274
+
275
+ .stp-bottom-left .stp-panel {
276
+ left: 0;
277
+ }
278
+
279
+ .stp-top-right .stp-panel,
280
+ .stp-top-left .stp-panel {
281
+ top: 60px;
282
+ }
283
+
284
+ .stp-top-right .stp-panel {
285
+ right: 0;
286
+ }
287
+
288
+ .stp-top-left .stp-panel {
289
+ left: 0;
290
+ }
291
+
292
+ .stp-panel-inline {
293
+ position: relative !important;
294
+ bottom: auto !important;
295
+ right: auto !important;
296
+ left: auto !important;
297
+ top: auto !important;
298
+ margin-top: 8px;
299
+ animation: none;
300
+ }
301
+
302
+ /* Horizontal layout for inline mode */
303
+ .stp-horizontal {
304
+ width: auto;
305
+ max-width: 100%;
306
+ }
307
+
308
+ .stp-horizontal .stp-header {
309
+ display: none;
310
+ }
311
+
312
+ .stp-horizontal .stp-list {
313
+ display: flex;
314
+ flex-wrap: wrap;
315
+ gap: 8px;
316
+ max-height: none;
317
+ padding: var(--stp-space);
318
+ }
319
+
320
+ .stp-horizontal .stp-option {
321
+ flex-direction: column;
322
+ width: auto;
323
+ padding: var(--stp-space-sm);
324
+ gap: 6px;
325
+ }
326
+
327
+ .stp-horizontal .stp-info {
328
+ display: none;
329
+ }
330
+
331
+ .stp-horizontal .stp-check {
332
+ position: absolute;
333
+ top: -4px;
334
+ right: -4px;
335
+ background: var(--stp-accent);
336
+ border-radius: 50%;
337
+ padding: 2px;
338
+ width: 16px;
339
+ height: 16px;
340
+ }
341
+
342
+ .stp-horizontal .stp-option {
343
+ position: relative;
344
+ }
345
+
346
+ .stp-horizontal .stp-colors {
347
+ width: 40px;
348
+ height: 40px;
349
+ }
350
+
351
+ @keyframes stp-slideIn {
352
+ from {
353
+ opacity: 0;
354
+ transform: translateY(10px);
355
+ }
356
+ to {
357
+ opacity: 1;
358
+ transform: translateY(0);
359
+ }
360
+ }
361
+
362
+ .stp-header {
363
+ display: flex;
364
+ justify-content: space-between;
365
+ align-items: center;
366
+ padding: var(--stp-space);
367
+ border-bottom: 1px solid var(--stp-border);
368
+ }
369
+
370
+ .stp-title {
371
+ margin: 0;
372
+ font-size: 0.9rem;
373
+ font-weight: 600;
374
+ color: var(--stp-text);
375
+ }
376
+
377
+ .stp-close {
378
+ background: transparent;
379
+ border: none;
380
+ color: var(--stp-text-muted);
381
+ cursor: pointer;
382
+ padding: 4px;
383
+ border-radius: var(--stp-radius-sm);
384
+ display: flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ transition: background-color var(--stp-transition), color var(--stp-transition);
388
+ }
389
+
390
+ .stp-close:hover {
391
+ background: var(--stp-bg-hover);
392
+ color: var(--stp-text);
393
+ }
394
+
395
+ .stp-list {
396
+ padding: var(--stp-space-sm);
397
+ max-height: 400px;
398
+ overflow-y: auto;
399
+ scrollbar-width: thin;
400
+ scrollbar-color: var(--stp-accent) transparent;
401
+ }
402
+
403
+ .stp-list::-webkit-scrollbar {
404
+ width: 6px;
405
+ }
406
+
407
+ .stp-list::-webkit-scrollbar-track {
408
+ background: transparent;
409
+ }
410
+
411
+ .stp-list::-webkit-scrollbar-thumb {
412
+ background: rgba(168, 85, 247, 0.5);
413
+ border-radius: 3px;
414
+ }
415
+
416
+ .stp-list::-webkit-scrollbar-thumb:hover {
417
+ background: var(--stp-accent);
418
+ }
419
+
420
+ .stp-option {
421
+ width: 100%;
422
+ display: flex;
423
+ align-items: center;
424
+ gap: var(--stp-space);
425
+ padding: var(--stp-space-sm) var(--stp-space);
426
+ background: transparent;
427
+ border: none;
428
+ border-radius: var(--stp-radius-sm);
429
+ cursor: pointer;
430
+ transition: background-color var(--stp-transition);
431
+ text-align: left;
432
+ }
433
+
434
+ .stp-option:hover {
435
+ background: var(--stp-bg-hover);
436
+ }
437
+
438
+ .stp-option.stp-active {
439
+ background: var(--stp-bg-active);
440
+ }
441
+
442
+ .stp-preview {
443
+ flex-shrink: 0;
444
+ }
445
+
446
+ .stp-colors {
447
+ display: grid;
448
+ grid-template-columns: repeat(2, 1fr);
449
+ gap: 2px;
450
+ width: 32px;
451
+ height: 32px;
452
+ border-radius: 6px;
453
+ overflow: hidden;
454
+ }
455
+
456
+ .stp-colors span {
457
+ width: 100%;
458
+ height: 100%;
459
+ }
460
+
461
+ .stp-info {
462
+ flex: 1;
463
+ min-width: 0;
464
+ display: flex;
465
+ flex-direction: column;
466
+ gap: 2px;
467
+ }
468
+
469
+ .stp-name {
470
+ font-size: 0.85rem;
471
+ font-weight: 500;
472
+ color: var(--stp-text);
473
+ }
474
+
475
+ .stp-desc {
476
+ font-size: 0.7rem;
477
+ color: var(--stp-text-muted);
478
+ white-space: nowrap;
479
+ overflow: hidden;
480
+ text-overflow: ellipsis;
481
+ }
482
+
483
+ .stp-check {
484
+ flex-shrink: 0;
485
+ color: #8ad4d4;
486
+ }
487
+
488
+ @media (max-width: 640px) {
489
+ .stp-bottom-right,
490
+ .stp-bottom-left {
491
+ bottom: 16px;
492
+ }
493
+
494
+ .stp-bottom-right {
495
+ right: 16px;
496
+ }
497
+
498
+ .stp-bottom-left {
499
+ left: 16px;
500
+ }
501
+
502
+ .stp-panel {
503
+ width: calc(100vw - 48px);
504
+ }
505
+
506
+ .stp-bottom-right .stp-panel {
507
+ right: -16px;
508
+ }
509
+
510
+ .stp-bottom-left .stp-panel {
511
+ left: -16px;
512
+ }
513
+ }
514
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { Theme, ThemePickerConfig } from './types.js';
2
+ import { type ThemeStore } from './store.js';
3
+ interface Props {
4
+ /** Configuration options */
5
+ config?: ThemePickerConfig;
6
+ /** Custom theme store (if not provided, creates one internally) */
7
+ store?: ThemeStore;
8
+ /** Position of the trigger button */
9
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
10
+ /** Whether to show the trigger button (false = inline mode) */
11
+ showTrigger?: boolean;
12
+ /** Layout direction for inline mode */
13
+ layout?: 'vertical' | 'horizontal';
14
+ /** Callback when theme changes */
15
+ onThemeChange?: (themeId: string, theme: Theme) => void;
16
+ }
17
+ declare const ThemePicker: import("svelte").Component<Props, {}, "">;
18
+ type ThemePicker = ReturnType<typeof ThemePicker>;
19
+ export default ThemePicker;
@@ -0,0 +1,47 @@
1
+ import type { Theme, ThemeCatalog, ThemeCatalogEntry, ThemeFilterOptions, ThemeMeta } from './types.js';
2
+ /**
3
+ * Convert a simple themes record to a catalog with default metadata
4
+ */
5
+ export declare function themesToCatalog(themes: Record<string, Theme>, defaultMeta?: Partial<ThemeMeta>): ThemeCatalog;
6
+ /**
7
+ * Extract just the themes from a catalog (for use with ThemePicker)
8
+ */
9
+ export declare function catalogToThemes(catalog: ThemeCatalog): Record<string, Theme>;
10
+ /**
11
+ * Filter themes from a catalog based on options
12
+ */
13
+ export declare function filterCatalog(catalog: ThemeCatalog, options?: ThemeFilterOptions): ThemeCatalog;
14
+ /**
15
+ * Get only active themes from a catalog as a simple themes record
16
+ */
17
+ export declare function getActiveThemes(catalog: ThemeCatalog): Record<string, Theme>;
18
+ /**
19
+ * Get themes by tag from a catalog
20
+ */
21
+ export declare function getThemesByTag(catalog: ThemeCatalog, tag: string): Record<string, Theme>;
22
+ /**
23
+ * Get themes by any of the given tags
24
+ */
25
+ export declare function getThemesByAnyTag(catalog: ThemeCatalog, tags: string[]): Record<string, Theme>;
26
+ /**
27
+ * Sort catalog entries by their order property
28
+ */
29
+ export declare function sortCatalog(catalog: ThemeCatalog): ThemeCatalog;
30
+ /**
31
+ * Get all unique tags from a catalog
32
+ */
33
+ export declare function getCatalogTags(catalog: ThemeCatalog): string[];
34
+ /**
35
+ * Create a catalog entry with defaults
36
+ */
37
+ export declare function createCatalogEntry(theme: Theme, meta?: ThemeMeta): ThemeCatalogEntry;
38
+ /**
39
+ * Merge multiple catalogs (later catalogs override earlier ones)
40
+ */
41
+ export declare function mergeCatalogs(...catalogs: ThemeCatalog[]): ThemeCatalog;
42
+ /**
43
+ * Load themes from a JSON object (useful for loading from files)
44
+ * The JSON should match the ThemeCatalog structure
45
+ * @throws {Error} If the JSON is not a valid ThemeCatalog
46
+ */
47
+ export declare function loadCatalogFromJSON(json: unknown): ThemeCatalog;