@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.
Files changed (108) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +20 -0
  3. package/package.json +128 -0
  4. package/src/app.d.ts +8 -0
  5. package/src/app.html +12 -0
  6. package/src/astro/CTABand.astro +32 -0
  7. package/src/astro/Hero.astro +58 -0
  8. package/src/astro/SiteFooter.astro +47 -0
  9. package/src/astro/SiteHeader.astro +46 -0
  10. package/src/lib/actions/portal.ts +16 -0
  11. package/src/lib/assets/runwisp-logo.svg +16 -0
  12. package/src/lib/components/Accordion.svelte +20 -0
  13. package/src/lib/components/AccordionItem.svelte +74 -0
  14. package/src/lib/components/Alert.svelte +107 -0
  15. package/src/lib/components/AlertDialog.svelte +63 -0
  16. package/src/lib/components/Avatar.svelte +88 -0
  17. package/src/lib/components/Badge.svelte +131 -0
  18. package/src/lib/components/Breadcrumb.svelte +58 -0
  19. package/src/lib/components/Button.svelte +69 -0
  20. package/src/lib/components/Card.svelte +70 -0
  21. package/src/lib/components/Checkbox.svelte +90 -0
  22. package/src/lib/components/CodeBlock.svelte +66 -0
  23. package/src/lib/components/CronInput.svelte +130 -0
  24. package/src/lib/components/DataGrid.svelte +368 -0
  25. package/src/lib/components/Drawer.svelte +156 -0
  26. package/src/lib/components/Dropdown.svelte +195 -0
  27. package/src/lib/components/DurationInput.svelte +170 -0
  28. package/src/lib/components/EmptyState.svelte +46 -0
  29. package/src/lib/components/ErrorState.svelte +73 -0
  30. package/src/lib/components/FeatureCard.svelte +35 -0
  31. package/src/lib/components/FeatureGrid.svelte +37 -0
  32. package/src/lib/components/FormField.svelte +53 -0
  33. package/src/lib/components/Heading.svelte +46 -0
  34. package/src/lib/components/Input.svelte +114 -0
  35. package/src/lib/components/Kbd.svelte +31 -0
  36. package/src/lib/components/Link.svelte +56 -0
  37. package/src/lib/components/LinkButton.svelte +55 -0
  38. package/src/lib/components/LogConsole.svelte +424 -0
  39. package/src/lib/components/Logo.svelte +34 -0
  40. package/src/lib/components/Modal.svelte +138 -0
  41. package/src/lib/components/PageContainer.svelte +38 -0
  42. package/src/lib/components/Pagination.svelte +96 -0
  43. package/src/lib/components/Popover.svelte +130 -0
  44. package/src/lib/components/Progress.svelte +68 -0
  45. package/src/lib/components/Prose.svelte +43 -0
  46. package/src/lib/components/Radio.svelte +82 -0
  47. package/src/lib/components/RadioGroup.svelte +53 -0
  48. package/src/lib/components/Select.svelte +454 -0
  49. package/src/lib/components/Separator.svelte +30 -0
  50. package/src/lib/components/Skeleton.svelte +75 -0
  51. package/src/lib/components/Sparkline.svelte +58 -0
  52. package/src/lib/components/Spinner.svelte +44 -0
  53. package/src/lib/components/StatusIndicator.svelte +203 -0
  54. package/src/lib/components/Table.svelte +19 -0
  55. package/src/lib/components/TableBody.svelte +17 -0
  56. package/src/lib/components/TableCell.svelte +34 -0
  57. package/src/lib/components/TableHead.svelte +19 -0
  58. package/src/lib/components/TableRow.svelte +22 -0
  59. package/src/lib/components/Tabs.svelte +148 -0
  60. package/src/lib/components/Text.svelte +57 -0
  61. package/src/lib/components/Textarea.svelte +81 -0
  62. package/src/lib/components/ThemeToggle.svelte +47 -0
  63. package/src/lib/components/TimezoneSelect.svelte +127 -0
  64. package/src/lib/components/ToastContainer.svelte +84 -0
  65. package/src/lib/components/Toggle.svelte +84 -0
  66. package/src/lib/components/Tooltip.svelte +56 -0
  67. package/src/lib/components/button-styles.ts +51 -0
  68. package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
  69. package/src/lib/components/dashboard/RunsList.svelte +331 -0
  70. package/src/lib/components/dashboard/run-helpers.ts +12 -0
  71. package/src/lib/components/dashboard/status-config.ts +141 -0
  72. package/src/lib/components/dashboard/types.ts +35 -0
  73. package/src/lib/index.ts +122 -0
  74. package/src/lib/log-console/LogCache.svelte.ts +93 -0
  75. package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
  76. package/src/lib/log-console/types.ts +24 -0
  77. package/src/lib/theme-tokens.css +235 -0
  78. package/src/lib/theme.css +276 -0
  79. package/src/lib/utils/error.ts +18 -0
  80. package/src/lib/utils/format.ts +76 -0
  81. package/src/lib/utils/id.ts +6 -0
  82. package/src/lib/utils/toast.svelte.ts +87 -0
  83. package/src/lib/utils/toast.ts +60 -0
  84. package/src/routes/+layout.svelte +10 -0
  85. package/src/routes/+page.svelte +5 -0
  86. package/src/routes/layout.css +3 -0
  87. package/src/stories/Alert.stories.svelte +64 -0
  88. package/src/stories/Avatar.stories.svelte +76 -0
  89. package/src/stories/Badge.stories.svelte +76 -0
  90. package/src/stories/Breadcrumb.stories.svelte +64 -0
  91. package/src/stories/Button.stories.svelte +101 -0
  92. package/src/stories/Card.stories.svelte +106 -0
  93. package/src/stories/Checkbox.stories.svelte +77 -0
  94. package/src/stories/DataGrid.stories.svelte +130 -0
  95. package/src/stories/Dropdown.stories.svelte +81 -0
  96. package/src/stories/EmptyState.stories.svelte +74 -0
  97. package/src/stories/Input.stories.svelte +88 -0
  98. package/src/stories/Logo.stories.svelte +82 -0
  99. package/src/stories/Modal.stories.svelte +101 -0
  100. package/src/stories/Progress.stories.svelte +65 -0
  101. package/src/stories/Select.stories.svelte +158 -0
  102. package/src/stories/Tabs.stories.svelte +103 -0
  103. package/src/stories/Textarea.stories.svelte +45 -0
  104. package/src/stories/Toggle.stories.svelte +74 -0
  105. package/src/stories/Tooltip.stories.svelte +77 -0
  106. package/src/stories/Welcome.mdx +241 -0
  107. package/src/stories/assets/runwisp_c.svg +16 -0
  108. 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>