@runwisp/ui 0.4.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/LICENSE +215 -0
- package/README.md +20 -0
- package/package.json +128 -0
- package/src/app.d.ts +8 -0
- package/src/app.html +12 -0
- package/src/astro/CTABand.astro +32 -0
- package/src/astro/Hero.astro +58 -0
- package/src/astro/SiteFooter.astro +47 -0
- package/src/astro/SiteHeader.astro +46 -0
- package/src/lib/actions/portal.ts +16 -0
- package/src/lib/assets/runwisp-logo.svg +16 -0
- package/src/lib/components/Accordion.svelte +20 -0
- package/src/lib/components/AccordionItem.svelte +74 -0
- package/src/lib/components/Alert.svelte +107 -0
- package/src/lib/components/AlertDialog.svelte +63 -0
- package/src/lib/components/Avatar.svelte +88 -0
- package/src/lib/components/Badge.svelte +131 -0
- package/src/lib/components/Breadcrumb.svelte +58 -0
- package/src/lib/components/Button.svelte +69 -0
- package/src/lib/components/Card.svelte +70 -0
- package/src/lib/components/Checkbox.svelte +90 -0
- package/src/lib/components/CodeBlock.svelte +66 -0
- package/src/lib/components/CronInput.svelte +130 -0
- package/src/lib/components/DataGrid.svelte +368 -0
- package/src/lib/components/Drawer.svelte +156 -0
- package/src/lib/components/Dropdown.svelte +195 -0
- package/src/lib/components/DurationInput.svelte +170 -0
- package/src/lib/components/EmptyState.svelte +46 -0
- package/src/lib/components/ErrorState.svelte +73 -0
- package/src/lib/components/FeatureCard.svelte +35 -0
- package/src/lib/components/FeatureGrid.svelte +37 -0
- package/src/lib/components/FormField.svelte +53 -0
- package/src/lib/components/Heading.svelte +46 -0
- package/src/lib/components/Input.svelte +114 -0
- package/src/lib/components/Kbd.svelte +31 -0
- package/src/lib/components/Link.svelte +56 -0
- package/src/lib/components/LinkButton.svelte +55 -0
- package/src/lib/components/LogConsole.svelte +424 -0
- package/src/lib/components/Logo.svelte +34 -0
- package/src/lib/components/Modal.svelte +138 -0
- package/src/lib/components/PageContainer.svelte +38 -0
- package/src/lib/components/Pagination.svelte +96 -0
- package/src/lib/components/Popover.svelte +130 -0
- package/src/lib/components/Progress.svelte +68 -0
- package/src/lib/components/Prose.svelte +43 -0
- package/src/lib/components/Radio.svelte +82 -0
- package/src/lib/components/RadioGroup.svelte +53 -0
- package/src/lib/components/Select.svelte +454 -0
- package/src/lib/components/Separator.svelte +30 -0
- package/src/lib/components/Skeleton.svelte +75 -0
- package/src/lib/components/Sparkline.svelte +58 -0
- package/src/lib/components/Spinner.svelte +44 -0
- package/src/lib/components/StatusIndicator.svelte +203 -0
- package/src/lib/components/Table.svelte +19 -0
- package/src/lib/components/TableBody.svelte +17 -0
- package/src/lib/components/TableCell.svelte +34 -0
- package/src/lib/components/TableHead.svelte +19 -0
- package/src/lib/components/TableRow.svelte +22 -0
- package/src/lib/components/Tabs.svelte +148 -0
- package/src/lib/components/Text.svelte +57 -0
- package/src/lib/components/Textarea.svelte +81 -0
- package/src/lib/components/ThemeToggle.svelte +47 -0
- package/src/lib/components/TimezoneSelect.svelte +127 -0
- package/src/lib/components/ToastContainer.svelte +84 -0
- package/src/lib/components/Toggle.svelte +84 -0
- package/src/lib/components/Tooltip.svelte +56 -0
- package/src/lib/components/button-styles.ts +51 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
- package/src/lib/components/dashboard/RunsList.svelte +331 -0
- package/src/lib/components/dashboard/run-helpers.ts +12 -0
- package/src/lib/components/dashboard/status-config.ts +141 -0
- package/src/lib/components/dashboard/types.ts +35 -0
- package/src/lib/index.ts +122 -0
- package/src/lib/log-console/LogCache.svelte.ts +93 -0
- package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
- package/src/lib/log-console/types.ts +24 -0
- package/src/lib/theme-tokens.css +235 -0
- package/src/lib/theme.css +276 -0
- package/src/lib/utils/error.ts +18 -0
- package/src/lib/utils/format.ts +76 -0
- package/src/lib/utils/id.ts +6 -0
- package/src/lib/utils/toast.svelte.ts +87 -0
- package/src/lib/utils/toast.ts +60 -0
- package/src/routes/+layout.svelte +10 -0
- package/src/routes/+page.svelte +5 -0
- package/src/routes/layout.css +3 -0
- package/src/stories/Alert.stories.svelte +64 -0
- package/src/stories/Avatar.stories.svelte +76 -0
- package/src/stories/Badge.stories.svelte +76 -0
- package/src/stories/Breadcrumb.stories.svelte +64 -0
- package/src/stories/Button.stories.svelte +101 -0
- package/src/stories/Card.stories.svelte +106 -0
- package/src/stories/Checkbox.stories.svelte +77 -0
- package/src/stories/DataGrid.stories.svelte +130 -0
- package/src/stories/Dropdown.stories.svelte +81 -0
- package/src/stories/EmptyState.stories.svelte +74 -0
- package/src/stories/Input.stories.svelte +88 -0
- package/src/stories/Logo.stories.svelte +82 -0
- package/src/stories/Modal.stories.svelte +101 -0
- package/src/stories/Progress.stories.svelte +65 -0
- package/src/stories/Select.stories.svelte +158 -0
- package/src/stories/Tabs.stories.svelte +103 -0
- package/src/stories/Textarea.stories.svelte +45 -0
- package/src/stories/Toggle.stories.svelte +74 -0
- package/src/stories/Tooltip.stories.svelte +77 -0
- package/src/stories/Welcome.mdx +241 -0
- package/src/stories/assets/runwisp_c.svg +16 -0
- package/src/svelte-shims.d.ts +18 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script module lang="ts">
|
|
5
|
+
import type { Component } from "svelte";
|
|
6
|
+
|
|
7
|
+
export interface SelectOption {
|
|
8
|
+
value: unknown;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string | undefined;
|
|
11
|
+
icon?: Component | undefined;
|
|
12
|
+
disabled?: boolean | undefined;
|
|
13
|
+
group?: string | undefined;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { tick, onDestroy } from "svelte";
|
|
20
|
+
import { ChevronDown, Check, Search } from "@lucide/svelte";
|
|
21
|
+
import { fade } from "svelte/transition";
|
|
22
|
+
import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
|
|
23
|
+
import { generateUlid } from "@runwisp/common";
|
|
24
|
+
|
|
25
|
+
type SelectSize = "sm" | "md" | "lg";
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
value?: unknown;
|
|
29
|
+
options?: SelectOption[];
|
|
30
|
+
label?: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
searchable?: boolean;
|
|
34
|
+
size?: SelectSize;
|
|
35
|
+
error?: string;
|
|
36
|
+
hint?: string;
|
|
37
|
+
class?: string;
|
|
38
|
+
id?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
onchange?: (value: unknown) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
value = $bindable(),
|
|
45
|
+
options = [],
|
|
46
|
+
label,
|
|
47
|
+
placeholder = "Select...",
|
|
48
|
+
disabled = false,
|
|
49
|
+
searchable = false,
|
|
50
|
+
size = "md",
|
|
51
|
+
error,
|
|
52
|
+
hint,
|
|
53
|
+
class: className = "",
|
|
54
|
+
id = `select-${generateUlid()}`,
|
|
55
|
+
name,
|
|
56
|
+
onchange,
|
|
57
|
+
onsearch, // New prop
|
|
58
|
+
}: Props & { onsearch?: (query: string) => void } = $props();
|
|
59
|
+
|
|
60
|
+
let isOpen = $state(false);
|
|
61
|
+
let searchQuery = $state("");
|
|
62
|
+
let triggerEl = $state<HTMLElement | null>(null);
|
|
63
|
+
let menuEl = $state<HTMLElement | null>(null);
|
|
64
|
+
let searchInputEl = $state<HTMLInputElement | null>(null);
|
|
65
|
+
let focusedIndex = $state(-1);
|
|
66
|
+
let cleanupFloating = $state<(() => void) | null>(null);
|
|
67
|
+
|
|
68
|
+
$effect(() => {
|
|
69
|
+
if (searchable && onsearch) {
|
|
70
|
+
onsearch(searchQuery);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const selectedOption = $derived(options.find((o) => o.value === value));
|
|
75
|
+
|
|
76
|
+
const filteredOptions = $derived.by(() => {
|
|
77
|
+
if (onsearch) return options; // If external search, trust options are already filtered/updated
|
|
78
|
+
if (!searchable || !searchQuery) return options;
|
|
79
|
+
const q = searchQuery.toLowerCase();
|
|
80
|
+
return options.filter(
|
|
81
|
+
(o) =>
|
|
82
|
+
o.label.toLowerCase().includes(q) ||
|
|
83
|
+
o.description?.toLowerCase().includes(q) ||
|
|
84
|
+
o.group?.toLowerCase().includes(q),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const groupedOptions = $derived.by(() => {
|
|
89
|
+
const groups: Record<string, SelectOption[]> = {};
|
|
90
|
+
const noGroup: SelectOption[] = [];
|
|
91
|
+
|
|
92
|
+
filteredOptions.forEach((opt) => {
|
|
93
|
+
const group = opt.group;
|
|
94
|
+
if (group) {
|
|
95
|
+
if (!groups[group]) groups[group] = [];
|
|
96
|
+
groups[group]!.push(opt);
|
|
97
|
+
} else {
|
|
98
|
+
noGroup.push(opt);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { groups, noGroup };
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const flattenedVisibleOptions = $derived.by(() => {
|
|
106
|
+
const list: SelectOption[] = [...groupedOptions.noGroup];
|
|
107
|
+
Object.values(groupedOptions.groups).forEach((g) => list.push(...g));
|
|
108
|
+
return list;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
import { portal } from "../actions/portal.js";
|
|
112
|
+
|
|
113
|
+
async function setupFloating() {
|
|
114
|
+
if (!triggerEl || !menuEl) return;
|
|
115
|
+
|
|
116
|
+
menuEl.style.width = `${triggerEl.offsetWidth}px`;
|
|
117
|
+
|
|
118
|
+
cleanupFloating = autoUpdate(triggerEl, menuEl, () => {
|
|
119
|
+
if (!triggerEl || !menuEl) return;
|
|
120
|
+
void computePosition(triggerEl, menuEl, {
|
|
121
|
+
placement: "bottom-start",
|
|
122
|
+
middleware: [offset(6), flip(), shift({ padding: 10 })],
|
|
123
|
+
}).then(({ x, y }) => {
|
|
124
|
+
if (menuEl) {
|
|
125
|
+
Object.assign(menuEl.style, {
|
|
126
|
+
left: `${x}px`,
|
|
127
|
+
top: `${y}px`,
|
|
128
|
+
position: "absolute",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toggle() {
|
|
136
|
+
if (disabled) return;
|
|
137
|
+
isOpen = !isOpen;
|
|
138
|
+
if (isOpen) {
|
|
139
|
+
searchQuery = "";
|
|
140
|
+
focusedIndex = flattenedVisibleOptions.findIndex((o) => o.value === value);
|
|
141
|
+
if (focusedIndex === -1) focusedIndex = 0;
|
|
142
|
+
|
|
143
|
+
void tick().then(() => {
|
|
144
|
+
void setupFloating();
|
|
145
|
+
if (searchable && searchInputEl) {
|
|
146
|
+
searchInputEl.focus();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
cleanupFloating?.();
|
|
151
|
+
cleanupFloating = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function select(option: SelectOption) {
|
|
156
|
+
if (option.disabled) return;
|
|
157
|
+
value = option.value;
|
|
158
|
+
onchange?.(value);
|
|
159
|
+
isOpen = false;
|
|
160
|
+
cleanupFloating?.(); // Cleanup immediately on select
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
164
|
+
if (!isOpen) {
|
|
165
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
toggle();
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
switch (e.key) {
|
|
173
|
+
case "Escape":
|
|
174
|
+
isOpen = false;
|
|
175
|
+
cleanupFloating?.();
|
|
176
|
+
triggerEl?.focus();
|
|
177
|
+
break;
|
|
178
|
+
case "ArrowDown":
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
focusedIndex = (focusedIndex + 1) % flattenedVisibleOptions.length;
|
|
181
|
+
scrollToOption(focusedIndex);
|
|
182
|
+
break;
|
|
183
|
+
case "ArrowUp":
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
focusedIndex =
|
|
186
|
+
(focusedIndex - 1 + flattenedVisibleOptions.length) %
|
|
187
|
+
flattenedVisibleOptions.length;
|
|
188
|
+
scrollToOption(focusedIndex);
|
|
189
|
+
break;
|
|
190
|
+
case "Enter":
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
if (focusedIndex >= 0 && focusedIndex < flattenedVisibleOptions.length) {
|
|
193
|
+
const selectedOpt = flattenedVisibleOptions[focusedIndex];
|
|
194
|
+
if (selectedOpt) select(selectedOpt);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function scrollToOption(index: number) {
|
|
201
|
+
const el = menuEl?.querySelector(`[data-index="${index}"]`);
|
|
202
|
+
if (!el || !menuEl) return;
|
|
203
|
+
|
|
204
|
+
const menuRect = menuEl.getBoundingClientRect();
|
|
205
|
+
const optionRect = el.getBoundingClientRect();
|
|
206
|
+
|
|
207
|
+
if (optionRect.bottom > menuRect.bottom) {
|
|
208
|
+
el.scrollIntoView({ block: "end", behavior: "smooth" });
|
|
209
|
+
} else if (optionRect.top < menuRect.top) {
|
|
210
|
+
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function handleOutsideClick(e: MouseEvent) {
|
|
215
|
+
if (
|
|
216
|
+
isOpen &&
|
|
217
|
+
triggerEl &&
|
|
218
|
+
!triggerEl.contains(e.target as Node) &&
|
|
219
|
+
menuEl &&
|
|
220
|
+
!menuEl.contains(e.target as Node)
|
|
221
|
+
) {
|
|
222
|
+
isOpen = false;
|
|
223
|
+
cleanupFloating?.();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
onDestroy(() => {
|
|
228
|
+
cleanupFloating?.();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const sizeClasses = {
|
|
232
|
+
sm: "px-2.5 py-1.5 text-xs",
|
|
233
|
+
md: "px-3.5 py-2 text-sm",
|
|
234
|
+
lg: "px-4 py-2.5 text-base",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const triggerBase = `
|
|
238
|
+
relative w-full text-left cursor-pointer
|
|
239
|
+
rounded-lg border bg-surface-raised
|
|
240
|
+
transition-all duration-normal ease-out
|
|
241
|
+
flex items-center justify-between
|
|
242
|
+
focus:outline-none focus:ring-4 focus:ring-ring/10 focus:border-ring
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
const borderClass = $derived(
|
|
246
|
+
error
|
|
247
|
+
? "border-danger-300 ring-danger-500/20"
|
|
248
|
+
: isOpen
|
|
249
|
+
? "border-ring ring-4 ring-ring/10"
|
|
250
|
+
: "border-outline hover:border-outline-hover shadow-sm hover:shadow-md",
|
|
251
|
+
);
|
|
252
|
+
</script>
|
|
253
|
+
|
|
254
|
+
<svelte:window onclick={handleOutsideClick} />
|
|
255
|
+
|
|
256
|
+
<div class="relative w-full {className}">
|
|
257
|
+
{#if label}
|
|
258
|
+
<label for={id} class="mb-1.5 block text-sm font-medium text-on-surface-muted">
|
|
259
|
+
{label}
|
|
260
|
+
</label>
|
|
261
|
+
{/if}
|
|
262
|
+
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
bind:this={triggerEl}
|
|
266
|
+
{id}
|
|
267
|
+
{disabled}
|
|
268
|
+
onclick={toggle}
|
|
269
|
+
onkeydown={handleKeydown}
|
|
270
|
+
class="
|
|
271
|
+
{triggerBase}
|
|
272
|
+
{sizeClasses[size]}
|
|
273
|
+
{borderClass}
|
|
274
|
+
{disabled
|
|
275
|
+
? 'cursor-not-allowed bg-surface-sunken text-on-surface-faint shadow-none'
|
|
276
|
+
: ''}
|
|
277
|
+
"
|
|
278
|
+
aria-haspopup="listbox"
|
|
279
|
+
aria-expanded={isOpen}
|
|
280
|
+
>
|
|
281
|
+
<div class="flex items-center gap-2 truncate pr-6">
|
|
282
|
+
{#if selectedOption?.icon}
|
|
283
|
+
<div class="text-on-surface-muted">
|
|
284
|
+
<selectedOption.icon size={size === "sm" ? 14 : 18} />
|
|
285
|
+
</div>
|
|
286
|
+
{/if}
|
|
287
|
+
{#if selectedOption}
|
|
288
|
+
<span class="truncate font-medium text-on-surface">
|
|
289
|
+
{selectedOption.label}
|
|
290
|
+
</span>
|
|
291
|
+
{:else}
|
|
292
|
+
<span class="truncate text-on-surface-faint">{placeholder}</span>
|
|
293
|
+
{/if}
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div
|
|
297
|
+
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-on-surface-faint"
|
|
298
|
+
>
|
|
299
|
+
<ChevronDown
|
|
300
|
+
size={16}
|
|
301
|
+
class="transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
</button>
|
|
305
|
+
|
|
306
|
+
<!-- Hidden input for form submission if needed -->
|
|
307
|
+
{#if name}
|
|
308
|
+
<input type="hidden" {name} {value} />
|
|
309
|
+
{/if}
|
|
310
|
+
|
|
311
|
+
<!-- Dropdown Menu -->
|
|
312
|
+
{#if isOpen}
|
|
313
|
+
<div
|
|
314
|
+
use:portal
|
|
315
|
+
bind:this={menuEl}
|
|
316
|
+
transition:fade={{ duration: 100 }}
|
|
317
|
+
class="z-[9999] min-w-[200px]"
|
|
318
|
+
>
|
|
319
|
+
<div
|
|
320
|
+
class="
|
|
321
|
+
overflow-hidden rounded-xl border border-outline bg-surface-overlay shadow-xl
|
|
322
|
+
ring-1 shadow-on-surface/5 ring-black/5
|
|
323
|
+
"
|
|
324
|
+
>
|
|
325
|
+
{#if searchable}
|
|
326
|
+
<div class="border-b border-outline-faint bg-surface-sunken/50 p-2">
|
|
327
|
+
<div class="relative">
|
|
328
|
+
<Search
|
|
329
|
+
size={14}
|
|
330
|
+
class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
|
|
331
|
+
/>
|
|
332
|
+
<input
|
|
333
|
+
bind:this={searchInputEl}
|
|
334
|
+
bind:value={searchQuery}
|
|
335
|
+
onkeydown={handleKeydown}
|
|
336
|
+
placeholder="Search..."
|
|
337
|
+
class="
|
|
338
|
+
w-full rounded-lg border border-outline bg-surface-raised py-1.5 pr-3 pl-8 text-xs
|
|
339
|
+
text-on-surface placeholder:text-on-surface-faint focus:border-ring focus:ring-1
|
|
340
|
+
focus:ring-ring focus:outline-none
|
|
341
|
+
"
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
<div
|
|
348
|
+
class="scrollbar-thin scrollbar-thumb-mist-200 scrollbar-track-transparent max-h-60 overflow-x-hidden overflow-y-auto p-1"
|
|
349
|
+
>
|
|
350
|
+
{#if flattenedVisibleOptions.length === 0}
|
|
351
|
+
<div class="px-4 py-8 text-center text-sm text-on-surface-muted">
|
|
352
|
+
No results found
|
|
353
|
+
</div>
|
|
354
|
+
{/if}
|
|
355
|
+
|
|
356
|
+
{#each groupedOptions.noGroup as option (option.value)}
|
|
357
|
+
{@const index = flattenedVisibleOptions.indexOf(option)}
|
|
358
|
+
{@render OptionItem({
|
|
359
|
+
option,
|
|
360
|
+
active: index === focusedIndex,
|
|
361
|
+
selected: option.value === value,
|
|
362
|
+
onclick: () => select(option),
|
|
363
|
+
onsemiactive: () => (focusedIndex = index),
|
|
364
|
+
"data-index": index,
|
|
365
|
+
})}
|
|
366
|
+
{/each}
|
|
367
|
+
|
|
368
|
+
{#each Object.entries(groupedOptions.groups) as [groupName, groupOptions] (groupName)}
|
|
369
|
+
<div
|
|
370
|
+
class="sticky top-0 z-10 mt-1 bg-surface-sunken/50 px-2 py-1.5 text-xs font-semibold tracking-wider text-on-surface-faint uppercase backdrop-blur-sm first:mt-0"
|
|
371
|
+
>
|
|
372
|
+
{groupName}
|
|
373
|
+
</div>
|
|
374
|
+
{#each groupOptions as option (option.value)}
|
|
375
|
+
{@const index = flattenedVisibleOptions.indexOf(option)}
|
|
376
|
+
{@render OptionItem({
|
|
377
|
+
option,
|
|
378
|
+
active: index === focusedIndex,
|
|
379
|
+
selected: option.value === value,
|
|
380
|
+
onclick: () => select(option),
|
|
381
|
+
onsemiactive: () => (focusedIndex = index),
|
|
382
|
+
"data-index": index,
|
|
383
|
+
})}
|
|
384
|
+
{/each}
|
|
385
|
+
{/each}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
{/if}
|
|
390
|
+
|
|
391
|
+
{#if error}
|
|
392
|
+
<p class="animate-in slide-in-from-top-1 fade-in mt-1.5 text-sm text-danger-600">
|
|
393
|
+
{error}
|
|
394
|
+
</p>
|
|
395
|
+
{:else if hint}
|
|
396
|
+
<p class="mt-1.5 text-xs text-on-surface-muted">
|
|
397
|
+
{hint}
|
|
398
|
+
</p>
|
|
399
|
+
{/if}
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<!-- Helper Component for items -->
|
|
403
|
+
{#snippet OptionItem({
|
|
404
|
+
option,
|
|
405
|
+
active,
|
|
406
|
+
selected,
|
|
407
|
+
onclick,
|
|
408
|
+
onsemiactive,
|
|
409
|
+
...rest
|
|
410
|
+
}: {
|
|
411
|
+
option: SelectOption;
|
|
412
|
+
active: boolean;
|
|
413
|
+
selected: boolean;
|
|
414
|
+
onclick: () => void;
|
|
415
|
+
onsemiactive: () => void;
|
|
416
|
+
[key: string]: unknown;
|
|
417
|
+
})}
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
{onclick}
|
|
421
|
+
onmouseenter={onsemiactive}
|
|
422
|
+
class="
|
|
423
|
+
flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-colors
|
|
424
|
+
{active ? 'bg-surface-sunken' : 'hover:bg-surface-sunken/50'}
|
|
425
|
+
{selected ? 'bg-primary-soft/50 font-medium text-on-surface' : 'text-on-surface-muted'}
|
|
426
|
+
{option.disabled
|
|
427
|
+
? 'pointer-events-none cursor-not-allowed opacity-50'
|
|
428
|
+
: 'cursor-pointer'}
|
|
429
|
+
"
|
|
430
|
+
{...rest}
|
|
431
|
+
>
|
|
432
|
+
{#if option.icon}
|
|
433
|
+
<div class="mt-0.5 shrink-0 {selected ? 'text-primary' : 'text-on-surface-faint'}">
|
|
434
|
+
<option.icon size={16} />
|
|
435
|
+
</div>
|
|
436
|
+
{/if}
|
|
437
|
+
|
|
438
|
+
<div class="min-w-0 flex-1">
|
|
439
|
+
<div class="flex items-center justify-between gap-2">
|
|
440
|
+
<span class="block truncate text-sm font-medium">{option.label}</span>
|
|
441
|
+
{#if selected}
|
|
442
|
+
<Check size={14} class="shrink-0 text-primary" />
|
|
443
|
+
{/if}
|
|
444
|
+
</div>
|
|
445
|
+
{#if option.description}
|
|
446
|
+
<div
|
|
447
|
+
class="mt-0.5 line-clamp-2 text-xs leading-normal text-wrap text-on-surface-muted"
|
|
448
|
+
>
|
|
449
|
+
{option.description}
|
|
450
|
+
</div>
|
|
451
|
+
{/if}
|
|
452
|
+
</div>
|
|
453
|
+
</button>
|
|
454
|
+
{/snippet}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
type SeparatorOrientation = "horizontal" | "vertical";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
orientation?: SeparatorOrientation;
|
|
9
|
+
label?: string;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { orientation = "horizontal", label, class: className = "" }: Props = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
{#if orientation === "vertical"}
|
|
17
|
+
<div
|
|
18
|
+
role="separator"
|
|
19
|
+
aria-orientation="vertical"
|
|
20
|
+
class="inline-block w-px self-stretch bg-outline {className}"
|
|
21
|
+
></div>
|
|
22
|
+
{:else if label}
|
|
23
|
+
<div role="separator" class="flex items-center gap-3 {className}">
|
|
24
|
+
<div class="h-px flex-1 bg-outline"></div>
|
|
25
|
+
<span class="text-xs font-medium text-on-surface-faint select-none">{label}</span>
|
|
26
|
+
<div class="h-px flex-1 bg-outline"></div>
|
|
27
|
+
</div>
|
|
28
|
+
{:else}
|
|
29
|
+
<hr class="border-outline {className}" />
|
|
30
|
+
{/if}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
type SkeletonShape = "line" | "circle" | "rect";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Shape variant */
|
|
9
|
+
shape?: SkeletonShape;
|
|
10
|
+
/** Number of skeleton rows (only for shape="line") */
|
|
11
|
+
rows?: number;
|
|
12
|
+
/** Show a header skeleton block (only for shape="line") */
|
|
13
|
+
header?: boolean;
|
|
14
|
+
/** Width (for circle/rect) */
|
|
15
|
+
width?: string;
|
|
16
|
+
/** Height (for circle/rect) */
|
|
17
|
+
height?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
shape = "line",
|
|
23
|
+
rows = 3,
|
|
24
|
+
header = true,
|
|
25
|
+
width,
|
|
26
|
+
height,
|
|
27
|
+
class: className = "",
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
{#if shape === "circle"}
|
|
32
|
+
<div
|
|
33
|
+
class="animate-pulse rounded-full bg-outline {className}"
|
|
34
|
+
style="width: {width ?? '2.5rem'}; height: {height ?? width ?? '2.5rem'}"
|
|
35
|
+
role="status"
|
|
36
|
+
aria-label="Loading"
|
|
37
|
+
>
|
|
38
|
+
<span class="sr-only">Loading...</span>
|
|
39
|
+
</div>
|
|
40
|
+
{:else if shape === "rect"}
|
|
41
|
+
<div
|
|
42
|
+
class="animate-pulse rounded-lg bg-outline {className}"
|
|
43
|
+
style="width: {width ?? '100%'}; height: {height ?? '2rem'}"
|
|
44
|
+
role="status"
|
|
45
|
+
aria-label="Loading"
|
|
46
|
+
>
|
|
47
|
+
<span class="sr-only">Loading...</span>
|
|
48
|
+
</div>
|
|
49
|
+
{:else}
|
|
50
|
+
<div class="animate-pulse space-y-6 p-6 {className}" role="status" aria-label="Loading content">
|
|
51
|
+
{#if header}
|
|
52
|
+
<div class="space-y-3">
|
|
53
|
+
<div class="h-7 w-48 rounded-lg bg-outline"></div>
|
|
54
|
+
<div class="h-4 w-72 rounded bg-outline-faint"></div>
|
|
55
|
+
</div>
|
|
56
|
+
{/if}
|
|
57
|
+
|
|
58
|
+
<div class="space-y-4">
|
|
59
|
+
{#each Array.from({ length: rows }, (_, i) => i) as i (i)}
|
|
60
|
+
<div class="space-y-2">
|
|
61
|
+
<div
|
|
62
|
+
class="h-4 rounded bg-outline-faint"
|
|
63
|
+
style="width: {70 + ((i * 17) % 30)}%"
|
|
64
|
+
></div>
|
|
65
|
+
<div
|
|
66
|
+
class="h-4 rounded bg-surface-sunken"
|
|
67
|
+
style="width: {40 + ((i * 23) % 40)}%"
|
|
68
|
+
></div>
|
|
69
|
+
</div>
|
|
70
|
+
{/each}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<span class="sr-only">Loading...</span>
|
|
74
|
+
</div>
|
|
75
|
+
{/if}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
interface Props {
|
|
6
|
+
data: number[];
|
|
7
|
+
color?: string;
|
|
8
|
+
fillColor?: string;
|
|
9
|
+
height?: number;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let {
|
|
14
|
+
data,
|
|
15
|
+
color = "#6366f1",
|
|
16
|
+
fillColor,
|
|
17
|
+
height = 40,
|
|
18
|
+
class: className = "",
|
|
19
|
+
}: Props = $props();
|
|
20
|
+
|
|
21
|
+
const viewBoxW = 200;
|
|
22
|
+
|
|
23
|
+
let pathD = $derived.by(() => {
|
|
24
|
+
if (data.length < 2) return "";
|
|
25
|
+
|
|
26
|
+
const max = 100;
|
|
27
|
+
const step = viewBoxW / (data.length - 1);
|
|
28
|
+
|
|
29
|
+
return data
|
|
30
|
+
.map((v: number, i: number) => {
|
|
31
|
+
const x = i * step;
|
|
32
|
+
const y = height - (v / max) * height;
|
|
33
|
+
return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
|
|
34
|
+
})
|
|
35
|
+
.join(" ");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let fillD = $derived.by(() => {
|
|
39
|
+
if (data.length < 2 || !pathD) return "";
|
|
40
|
+
const step = viewBoxW / (data.length - 1);
|
|
41
|
+
const lastX = (data.length - 1) * step;
|
|
42
|
+
return `${pathD} L${lastX.toFixed(1)},${height} L0,${height} Z`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let resolvedFill = $derived(fillColor ?? color + "18");
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<svg
|
|
49
|
+
viewBox="0 0 {viewBoxW} {height}"
|
|
50
|
+
preserveAspectRatio="none"
|
|
51
|
+
class="w-full {className}"
|
|
52
|
+
style="height: {height}px"
|
|
53
|
+
>
|
|
54
|
+
{#if data.length >= 2}
|
|
55
|
+
<path d={fillD} fill={resolvedFill} />
|
|
56
|
+
<path d={pathD} fill="none" stroke={color} stroke-width="1.5" stroke-linejoin="round" />
|
|
57
|
+
{/if}
|
|
58
|
+
</svg>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
type SpinnerSize = "xs" | "sm" | "md" | "lg";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
size?: SpinnerSize;
|
|
9
|
+
color?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
size = "md",
|
|
16
|
+
color = "currentColor",
|
|
17
|
+
label = "Loading",
|
|
18
|
+
class: className = "",
|
|
19
|
+
}: Props = $props();
|
|
20
|
+
|
|
21
|
+
const sizeClasses: Record<SpinnerSize, string> = {
|
|
22
|
+
xs: "h-3 w-3",
|
|
23
|
+
sm: "h-4 w-4",
|
|
24
|
+
md: "h-6 w-6",
|
|
25
|
+
lg: "h-8 w-8",
|
|
26
|
+
};
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div role="status" class="inline-flex {className}">
|
|
30
|
+
<svg
|
|
31
|
+
class="animate-spin {sizeClasses[size]}"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="none"
|
|
34
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
35
|
+
>
|
|
36
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke={color} stroke-width="4"></circle>
|
|
37
|
+
<path
|
|
38
|
+
class="opacity-75"
|
|
39
|
+
fill={color}
|
|
40
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
41
|
+
></path>
|
|
42
|
+
</svg>
|
|
43
|
+
<span class="sr-only">{label}</span>
|
|
44
|
+
</div>
|