@joewinke/jatui 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +47 -1
- package/dist/actions/reveal.d.ts +37 -0
- package/dist/actions/reveal.js +42 -0
- package/dist/components/AudioRecorder.svelte +749 -0
- package/dist/components/AudioRecorder.svelte.d.ts +36 -0
- package/dist/components/IconSelector.svelte +417 -0
- package/dist/components/IconSelector.svelte.d.ts +55 -0
- package/dist/components/InfiniteGrid.svelte +305 -0
- package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
- package/dist/components/InfiniteSearchGrid.svelte +131 -0
- package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
- package/dist/components/KeyboardShortcutsOverlay.svelte +40 -3
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +31 -16
- package/dist/components/MediaCaptureIcon.svelte +23 -0
- package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
- package/dist/components/ThumbnailGrid.svelte +137 -0
- package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
- package/dist/components/batch/BatchJobTable.svelte +549 -0
- package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
- package/dist/components/batch/QuotaMeter.svelte +78 -0
- package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
- package/dist/components/batch/batchQueue.d.ts +56 -0
- package/dist/components/batch/batchQueue.js +81 -0
- package/dist/components/batch/elapsedEstimate.d.ts +24 -0
- package/dist/components/batch/elapsedEstimate.js +25 -0
- package/dist/components/batch/pollOperation.d.ts +45 -0
- package/dist/components/batch/pollOperation.js +80 -0
- package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
- package/dist/components/batch/useElapsedEstimate.js +38 -0
- package/dist/components/infiniteGridLoader.d.ts +70 -0
- package/dist/components/infiniteGridLoader.js +141 -0
- package/dist/components/table/columnResize.d.ts +38 -0
- package/dist/components/table/columnResize.js +117 -0
- package/dist/components/table/index.d.ts +2 -0
- package/dist/components/table/index.js +1 -0
- package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
- package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
- package/dist/components/variant/variantCombinations.d.ts +75 -0
- package/dist/components/variant/variantCombinations.js +100 -0
- package/dist/history.d.ts +44 -0
- package/dist/history.js +87 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +27 -0
- package/dist/styles/motion.css +100 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
- package/dist/utils/keyboardShortcutRegistry.js +365 -0
- package/package.json +5 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** What a finished recording hands back to the host — mirrors MediaCaptureResult's shape for the audio kind. */
|
|
2
|
+
export interface AudioRecordingResult {
|
|
3
|
+
blob: Blob;
|
|
4
|
+
mimeType: string;
|
|
5
|
+
durationSeconds: number;
|
|
6
|
+
sizeBytes: number;
|
|
7
|
+
}
|
|
8
|
+
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
9
|
+
export declare function formatDuration(totalSeconds: number): string;
|
|
10
|
+
/** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
|
|
11
|
+
export declare function formatFileSize(bytes: number): string;
|
|
12
|
+
interface Props {
|
|
13
|
+
/** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
|
|
14
|
+
variant?: 'small' | 'large';
|
|
15
|
+
/** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
|
|
16
|
+
maxDurationMs?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Fired once the mic stream is live and recording has started. */
|
|
19
|
+
onRecordStart?: () => void;
|
|
20
|
+
onRecordPause?: () => void;
|
|
21
|
+
onRecordResume?: () => void;
|
|
22
|
+
/** Fired with the finished recording. May return a Promise — the
|
|
23
|
+
* component stays in "processing" until it settles, so hosts can
|
|
24
|
+
* upload/transcribe without the user re-triggering a recording. */
|
|
25
|
+
onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>;
|
|
26
|
+
/** Fired when the user cancels — no blob is produced. */
|
|
27
|
+
onRecordCancel?: () => void;
|
|
28
|
+
/** Fired on recorder errors (permission denied, no mic, start failure). */
|
|
29
|
+
onError?: (message: string) => void;
|
|
30
|
+
class?: string;
|
|
31
|
+
}
|
|
32
|
+
declare const AudioRecorder: import("svelte").Component<Props, {
|
|
33
|
+
reset: () => void;
|
|
34
|
+
}, "">;
|
|
35
|
+
type AudioRecorder = ReturnType<typeof AudioRecorder>;
|
|
36
|
+
export default AudioRecorder;
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* IconSelector — searchable, paged icon picker for assigning an icon to an item.
|
|
4
|
+
*
|
|
5
|
+
* Ported from chimaro's IconSelector.svelte and generalized: the icon set is
|
|
6
|
+
* fully prop-driven (no hardcoded list, no icon-library dependency). Pass any
|
|
7
|
+
* array of `IconSelectorOption`s — for lucide-svelte consumers that is simply
|
|
8
|
+
*
|
|
9
|
+
* const icons = Object.entries(lucideIconMap).map(([name, component]) => ({
|
|
10
|
+
* name, component,
|
|
11
|
+
* }));
|
|
12
|
+
* <IconSelector {icons} bind:selectedIcon={value} />
|
|
13
|
+
*
|
|
14
|
+
* Icons without a `component` (or all icons, via the `icon` snippet) can be
|
|
15
|
+
* rendered with the optional `icon` snippet for full control.
|
|
16
|
+
*/
|
|
17
|
+
import type { Component, Snippet } from 'svelte';
|
|
18
|
+
|
|
19
|
+
/** Props an icon component receives when rendered by the grid/trigger. */
|
|
20
|
+
export interface IconComponentProps {
|
|
21
|
+
size?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IconSelectorOption {
|
|
26
|
+
/** Stable identifier — this is the value stored/selected. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Human-readable label; defaults to `name`. Also searched. */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Extra search terms (e.g. synonyms). */
|
|
31
|
+
keywords?: string[];
|
|
32
|
+
/** Svelte component rendering the icon; receives `{ size, class }`. */
|
|
33
|
+
component?: Component<IconComponentProps>;
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script lang="ts">
|
|
38
|
+
import { slide } from 'svelte/transition';
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Name of the currently selected icon (from `IconSelectorOption.name`). */
|
|
42
|
+
selectedIcon?: string | null;
|
|
43
|
+
/** The icon set to pick from — replaces chimaro's hardcoded list. */
|
|
44
|
+
icons: IconSelectorOption[];
|
|
45
|
+
/** Fires with the chosen name, or `null` when cleared. */
|
|
46
|
+
onSelect?: (icon: string | null) => void;
|
|
47
|
+
/** Extra class forwarded to the rendered icon components (e.g. a text color). */
|
|
48
|
+
colorClass?: string;
|
|
49
|
+
/** Trigger row label. */
|
|
50
|
+
label?: string;
|
|
51
|
+
/** Auto-close the panel shortly after a selection is made. */
|
|
52
|
+
compact?: boolean;
|
|
53
|
+
/** Grid columns per page. */
|
|
54
|
+
columns?: number;
|
|
55
|
+
/** Icons shown per page (default: 4 rows of 8). */
|
|
56
|
+
perPage?: number;
|
|
57
|
+
/** Optional custom renderer; overrides `option.component` when provided. */
|
|
58
|
+
icon?: Snippet<[IconSelectorOption]>;
|
|
59
|
+
class?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let {
|
|
63
|
+
selectedIcon = $bindable(null),
|
|
64
|
+
icons,
|
|
65
|
+
onSelect = () => {},
|
|
66
|
+
colorClass = '',
|
|
67
|
+
label = 'Icon',
|
|
68
|
+
compact = false,
|
|
69
|
+
columns = 8,
|
|
70
|
+
perPage = 32,
|
|
71
|
+
icon,
|
|
72
|
+
class: className = ''
|
|
73
|
+
}: Props = $props();
|
|
74
|
+
|
|
75
|
+
let showSelector = $state(false);
|
|
76
|
+
let currentPage = $state(0);
|
|
77
|
+
let searchQuery = $state('');
|
|
78
|
+
let selectionAnimating = $state(false);
|
|
79
|
+
let lastSelectedIcon = $state<string | null>(null);
|
|
80
|
+
let searchInputEl: HTMLInputElement | undefined = $state(undefined);
|
|
81
|
+
let gridEl: HTMLElement | undefined = $state(undefined);
|
|
82
|
+
|
|
83
|
+
const filteredIcons = $derived.by(() => {
|
|
84
|
+
const q = searchQuery.trim().toLowerCase();
|
|
85
|
+
if (!q) return icons;
|
|
86
|
+
return icons.filter((i) => {
|
|
87
|
+
const haystack = `${i.label ?? i.name} ${i.name} ${(i.keywords ?? []).join(' ')}`.toLowerCase();
|
|
88
|
+
return haystack.includes(q);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const totalPages = $derived(Math.max(1, Math.ceil(filteredIcons.length / perPage)));
|
|
92
|
+
|
|
93
|
+
function nextPage() {
|
|
94
|
+
if (currentPage < totalPages - 1) currentPage++;
|
|
95
|
+
}
|
|
96
|
+
function prevPage() {
|
|
97
|
+
if (currentPage > 0) currentPage--;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleIconSelect(option: IconSelectorOption) {
|
|
101
|
+
if (option.name !== selectedIcon) {
|
|
102
|
+
lastSelectedIcon = option.name;
|
|
103
|
+
selectionAnimating = true;
|
|
104
|
+
selectedIcon = option.name;
|
|
105
|
+
onSelect(option.name);
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
selectionAnimating = false;
|
|
108
|
+
if (compact) setTimeout(() => (showSelector = false), 800);
|
|
109
|
+
}, 500);
|
|
110
|
+
} else {
|
|
111
|
+
onSelect(option.name);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clearIcon() {
|
|
116
|
+
selectedIcon = null;
|
|
117
|
+
onSelect(null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function focusGridStart() {
|
|
121
|
+
const btn = gridEl?.querySelector<HTMLButtonElement>('.active-page button.icon-btn');
|
|
122
|
+
btn?.focus();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Roving-focus keyboard nav over the visible page's icon buttons. */
|
|
126
|
+
function handleGridKeydown(e: KeyboardEvent) {
|
|
127
|
+
const page = gridEl?.querySelector('.active-page');
|
|
128
|
+
const buttons = Array.from(page?.querySelectorAll<HTMLButtonElement>('button.icon-btn') ?? []);
|
|
129
|
+
if (!buttons.length) return;
|
|
130
|
+
const idx = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
131
|
+
let next: number | null = null;
|
|
132
|
+
switch (e.key) {
|
|
133
|
+
case 'ArrowRight':
|
|
134
|
+
next = idx < 0 ? 0 : idx + 1;
|
|
135
|
+
break;
|
|
136
|
+
case 'ArrowLeft':
|
|
137
|
+
next = idx <= 0 ? 0 : idx - 1;
|
|
138
|
+
break;
|
|
139
|
+
case 'ArrowDown':
|
|
140
|
+
next = idx < 0 ? 0 : idx + columns;
|
|
141
|
+
break;
|
|
142
|
+
case 'ArrowUp':
|
|
143
|
+
next = idx < 0 ? 0 : idx - columns;
|
|
144
|
+
break;
|
|
145
|
+
case 'Home':
|
|
146
|
+
next = 0;
|
|
147
|
+
break;
|
|
148
|
+
case 'End':
|
|
149
|
+
next = buttons.length - 1;
|
|
150
|
+
break;
|
|
151
|
+
case 'PageDown':
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
nextPage();
|
|
154
|
+
return;
|
|
155
|
+
case 'PageUp':
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
prevPage();
|
|
158
|
+
return;
|
|
159
|
+
case 'Escape':
|
|
160
|
+
showSelector = false;
|
|
161
|
+
return;
|
|
162
|
+
case 'Enter':
|
|
163
|
+
// Explicit so Enter selects reliably even where the native button
|
|
164
|
+
// default is unavailable (synthetic/test key events).
|
|
165
|
+
if (document.activeElement?.classList.contains('icon-btn')) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
(document.activeElement as HTMLElement).click();
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
default:
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
buttons[Math.max(0, Math.min(next, buttons.length - 1))]?.focus();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reset pagination when the search query changes
|
|
178
|
+
$effect(() => {
|
|
179
|
+
void searchQuery;
|
|
180
|
+
currentPage = 0;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Reset search when the selector is closed and reopened
|
|
184
|
+
$effect(() => {
|
|
185
|
+
if (!showSelector) searchQuery = '';
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Focus the search input when the panel opens
|
|
189
|
+
$effect(() => {
|
|
190
|
+
if (showSelector) requestAnimationFrame(() => searchInputEl?.focus());
|
|
191
|
+
});
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<div class="form-control {className}">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
class="flex w-full items-center justify-between rounded-lg px-2 py-3 transition-colors hover:bg-base-200"
|
|
198
|
+
onclick={() => (showSelector = !showSelector)}
|
|
199
|
+
aria-expanded={showSelector}
|
|
200
|
+
>
|
|
201
|
+
<span class="label flex cursor-pointer items-center gap-2">
|
|
202
|
+
<span class="label-text font-medium">{label}</span>
|
|
203
|
+
{#if selectedIcon}
|
|
204
|
+
{@const selected = icons.find((i) => i.name === selectedIcon)}
|
|
205
|
+
<span class="icon-preview ml-5 inline-flex">
|
|
206
|
+
{#if icon}
|
|
207
|
+
{@render icon(selected!)}
|
|
208
|
+
{:else if selected?.component}
|
|
209
|
+
<selected.component size={22} class={colorClass} />
|
|
210
|
+
{/if}
|
|
211
|
+
</span>
|
|
212
|
+
{/if}
|
|
213
|
+
</span>
|
|
214
|
+
<svg
|
|
215
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
216
|
+
width="18"
|
|
217
|
+
height="18"
|
|
218
|
+
viewBox="0 0 24 24"
|
|
219
|
+
fill="none"
|
|
220
|
+
stroke="currentColor"
|
|
221
|
+
stroke-width="2"
|
|
222
|
+
stroke-linecap="round"
|
|
223
|
+
stroke-linejoin="round"
|
|
224
|
+
class="chevron-icon transition-transform duration-300 {showSelector ? 'rotate-180' : ''}"
|
|
225
|
+
>
|
|
226
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
|
|
230
|
+
{#if showSelector}
|
|
231
|
+
<div class="icon-selector-wrapper pt-2" transition:slide={{ duration: 200 }}>
|
|
232
|
+
<!-- Search bar -->
|
|
233
|
+
<div class="relative mb-3">
|
|
234
|
+
<input
|
|
235
|
+
bind:this={searchInputEl}
|
|
236
|
+
type="text"
|
|
237
|
+
bind:value={searchQuery}
|
|
238
|
+
placeholder="Search icons..."
|
|
239
|
+
class="input input-sm input-bordered focus:outline-none w-full pr-8"
|
|
240
|
+
aria-label="Search icons"
|
|
241
|
+
onkeydown={(e) => {
|
|
242
|
+
if (e.key === 'Escape') showSelector = false;
|
|
243
|
+
if (e.key === 'ArrowDown') {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
focusGridStart();
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
/>
|
|
249
|
+
{#if searchQuery}
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
class="absolute top-1/2 right-3 -translate-y-1/2 text-base-content/50 transition-colors hover:text-base-content"
|
|
253
|
+
onclick={() => (searchQuery = '')}
|
|
254
|
+
aria-label="Clear search"
|
|
255
|
+
>
|
|
256
|
+
<svg
|
|
257
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
258
|
+
width="14"
|
|
259
|
+
height="14"
|
|
260
|
+
viewBox="0 0 24 24"
|
|
261
|
+
fill="none"
|
|
262
|
+
stroke="currentColor"
|
|
263
|
+
stroke-width="2"
|
|
264
|
+
stroke-linecap="round"
|
|
265
|
+
stroke-linejoin="round"
|
|
266
|
+
>
|
|
267
|
+
<path d="M18 6 6 18"></path>
|
|
268
|
+
<path d="m6 6 12 12"></path>
|
|
269
|
+
</svg>
|
|
270
|
+
</button>
|
|
271
|
+
{/if}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<!-- Icons grid with overflow hidden for the pagination slide -->
|
|
275
|
+
<div
|
|
276
|
+
bind:this={gridEl}
|
|
277
|
+
class="icon-grid-container overflow-hidden rounded-lg bg-base-200"
|
|
278
|
+
onkeydown={handleGridKeydown}
|
|
279
|
+
>
|
|
280
|
+
<div
|
|
281
|
+
class="icon-grid-slider flex transition-transform duration-300"
|
|
282
|
+
style="transform: translateX(-{currentPage * 100}%)"
|
|
283
|
+
>
|
|
284
|
+
{#each Array(totalPages) as _, pageIndex (pageIndex)}
|
|
285
|
+
<div
|
|
286
|
+
class="icon-grid-page min-w-full shrink-0"
|
|
287
|
+
class:active-page={pageIndex === currentPage}
|
|
288
|
+
>
|
|
289
|
+
<div class="grid gap-2 p-4" style="grid-template-columns: repeat({columns}, minmax(0, 1fr))">
|
|
290
|
+
{#each filteredIcons.slice(pageIndex * perPage, (pageIndex + 1) * perPage) as option (option.name)}
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
class="icon-btn flex aspect-square items-center justify-center rounded-lg p-1 transition-all duration-200 hover:scale-105 hover:bg-base-300
|
|
294
|
+
{selectedIcon === option.name ? 'bg-base-300 ring-2 ring-primary/50' : ''}
|
|
295
|
+
{selectionAnimating && lastSelectedIcon === option.name ? 'selecting' : ''}"
|
|
296
|
+
onclick={() => handleIconSelect(option)}
|
|
297
|
+
aria-label="Select {option.label ?? option.name} icon"
|
|
298
|
+
aria-pressed={selectedIcon === option.name}
|
|
299
|
+
title={option.label ?? option.name}
|
|
300
|
+
>
|
|
301
|
+
{#if icon}
|
|
302
|
+
{@render icon(option)}
|
|
303
|
+
{:else if option.component}
|
|
304
|
+
<option.component size={24} class={colorClass} />
|
|
305
|
+
{/if}
|
|
306
|
+
</button>
|
|
307
|
+
{:else}
|
|
308
|
+
<div class="col-span-full py-6 text-center text-base-content/50">
|
|
309
|
+
No icons match your search
|
|
310
|
+
</div>
|
|
311
|
+
{/each}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
{/each}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<!-- Pagination controls -->
|
|
319
|
+
<div class="mt-3 flex items-center justify-between">
|
|
320
|
+
<div class="text-base-content/70 text-xs">
|
|
321
|
+
{#if filteredIcons.length > 0}
|
|
322
|
+
Showing {Math.min(filteredIcons.length, currentPage * perPage + 1)}-{Math.min(
|
|
323
|
+
filteredIcons.length,
|
|
324
|
+
(currentPage + 1) * perPage
|
|
325
|
+
)} of {filteredIcons.length} icons
|
|
326
|
+
{:else}
|
|
327
|
+
No icons found
|
|
328
|
+
{/if}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<div class="flex gap-2">
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
class="btn btn-ghost btn-sm btn-circle {currentPage === 0 ? 'opacity-30' : ''}"
|
|
335
|
+
onclick={prevPage}
|
|
336
|
+
disabled={currentPage === 0}
|
|
337
|
+
aria-label="Previous page"
|
|
338
|
+
>
|
|
339
|
+
<svg
|
|
340
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
341
|
+
width="16"
|
|
342
|
+
height="16"
|
|
343
|
+
viewBox="0 0 24 24"
|
|
344
|
+
fill="none"
|
|
345
|
+
stroke="currentColor"
|
|
346
|
+
stroke-width="2"
|
|
347
|
+
stroke-linecap="round"
|
|
348
|
+
stroke-linejoin="round"
|
|
349
|
+
>
|
|
350
|
+
<path d="m15 18-6-6 6-6"></path>
|
|
351
|
+
</svg>
|
|
352
|
+
</button>
|
|
353
|
+
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
class="btn btn-ghost btn-sm btn-circle {currentPage >= totalPages - 1 ? 'opacity-30' : ''}"
|
|
357
|
+
onclick={nextPage}
|
|
358
|
+
disabled={currentPage >= totalPages - 1}
|
|
359
|
+
aria-label="Next page"
|
|
360
|
+
>
|
|
361
|
+
<svg
|
|
362
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
363
|
+
width="16"
|
|
364
|
+
height="16"
|
|
365
|
+
viewBox="0 0 24 24"
|
|
366
|
+
fill="none"
|
|
367
|
+
stroke="currentColor"
|
|
368
|
+
stroke-width="2"
|
|
369
|
+
stroke-linecap="round"
|
|
370
|
+
stroke-linejoin="round"
|
|
371
|
+
>
|
|
372
|
+
<path d="m9 18 6-6-6-6"></path>
|
|
373
|
+
</svg>
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
{#if selectedIcon}
|
|
379
|
+
<div class="mt-2 flex justify-end">
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
class="text-base-content/60 hover:bg-base-200 hover:text-base-content rounded px-2 py-1 text-xs transition-colors"
|
|
383
|
+
onclick={clearIcon}
|
|
384
|
+
>
|
|
385
|
+
Clear icon
|
|
386
|
+
</button>
|
|
387
|
+
</div>
|
|
388
|
+
{/if}
|
|
389
|
+
</div>
|
|
390
|
+
{/if}
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<style>
|
|
394
|
+
.icon-btn {
|
|
395
|
+
position: relative;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.icon-btn.selecting {
|
|
399
|
+
transform: scale(1.15);
|
|
400
|
+
box-shadow:
|
|
401
|
+
0 0 0 4px oklch(var(--color-base-100) / 0.3),
|
|
402
|
+
0 0 15px oklch(var(--color-primary) / 0.5);
|
|
403
|
+
animation: icon-pulse 0.5s ease;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@keyframes icon-pulse {
|
|
407
|
+
0% {
|
|
408
|
+
transform: scale(1);
|
|
409
|
+
}
|
|
410
|
+
50% {
|
|
411
|
+
transform: scale(1.15);
|
|
412
|
+
}
|
|
413
|
+
100% {
|
|
414
|
+
transform: scale(1.1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IconSelector — searchable, paged icon picker for assigning an icon to an item.
|
|
3
|
+
*
|
|
4
|
+
* Ported from chimaro's IconSelector.svelte and generalized: the icon set is
|
|
5
|
+
* fully prop-driven (no hardcoded list, no icon-library dependency). Pass any
|
|
6
|
+
* array of `IconSelectorOption`s — for lucide-svelte consumers that is simply
|
|
7
|
+
*
|
|
8
|
+
* const icons = Object.entries(lucideIconMap).map(([name, component]) => ({
|
|
9
|
+
* name, component,
|
|
10
|
+
* }));
|
|
11
|
+
* <IconSelector {icons} bind:selectedIcon={value} />
|
|
12
|
+
*
|
|
13
|
+
* Icons without a `component` (or all icons, via the `icon` snippet) can be
|
|
14
|
+
* rendered with the optional `icon` snippet for full control.
|
|
15
|
+
*/
|
|
16
|
+
import type { Component, Snippet } from 'svelte';
|
|
17
|
+
/** Props an icon component receives when rendered by the grid/trigger. */
|
|
18
|
+
export interface IconComponentProps {
|
|
19
|
+
size?: number;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface IconSelectorOption {
|
|
23
|
+
/** Stable identifier — this is the value stored/selected. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Human-readable label; defaults to `name`. Also searched. */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Extra search terms (e.g. synonyms). */
|
|
28
|
+
keywords?: string[];
|
|
29
|
+
/** Svelte component rendering the icon; receives `{ size, class }`. */
|
|
30
|
+
component?: Component<IconComponentProps>;
|
|
31
|
+
}
|
|
32
|
+
interface Props {
|
|
33
|
+
/** Name of the currently selected icon (from `IconSelectorOption.name`). */
|
|
34
|
+
selectedIcon?: string | null;
|
|
35
|
+
/** The icon set to pick from — replaces chimaro's hardcoded list. */
|
|
36
|
+
icons: IconSelectorOption[];
|
|
37
|
+
/** Fires with the chosen name, or `null` when cleared. */
|
|
38
|
+
onSelect?: (icon: string | null) => void;
|
|
39
|
+
/** Extra class forwarded to the rendered icon components (e.g. a text color). */
|
|
40
|
+
colorClass?: string;
|
|
41
|
+
/** Trigger row label. */
|
|
42
|
+
label?: string;
|
|
43
|
+
/** Auto-close the panel shortly after a selection is made. */
|
|
44
|
+
compact?: boolean;
|
|
45
|
+
/** Grid columns per page. */
|
|
46
|
+
columns?: number;
|
|
47
|
+
/** Icons shown per page (default: 4 rows of 8). */
|
|
48
|
+
perPage?: number;
|
|
49
|
+
/** Optional custom renderer; overrides `option.component` when provided. */
|
|
50
|
+
icon?: Snippet<[IconSelectorOption]>;
|
|
51
|
+
class?: string;
|
|
52
|
+
}
|
|
53
|
+
declare const IconSelector: Component<Props, {}, "selectedIcon">;
|
|
54
|
+
type IconSelector = ReturnType<typeof IconSelector>;
|
|
55
|
+
export default IconSelector;
|