@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,156 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import { X } from "@lucide/svelte";
|
|
7
|
+
import { portal } from "../actions/portal.js";
|
|
8
|
+
import { fly } from "svelte/transition";
|
|
9
|
+
|
|
10
|
+
type Side = "left" | "right" | "top" | "bottom";
|
|
11
|
+
type DrawerSize = "sm" | "md" | "lg" | "xl";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
open?: boolean;
|
|
15
|
+
side?: Side;
|
|
16
|
+
size?: DrawerSize;
|
|
17
|
+
title?: string;
|
|
18
|
+
closable?: boolean;
|
|
19
|
+
onClose?: () => void;
|
|
20
|
+
header?: Snippet;
|
|
21
|
+
footer?: Snippet;
|
|
22
|
+
children?: Snippet;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
open = $bindable(false),
|
|
28
|
+
side = "right",
|
|
29
|
+
size = "md",
|
|
30
|
+
title,
|
|
31
|
+
closable = true,
|
|
32
|
+
onClose,
|
|
33
|
+
header,
|
|
34
|
+
footer,
|
|
35
|
+
children,
|
|
36
|
+
class: className = "",
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
const sizeValues: Record<DrawerSize, string> = {
|
|
40
|
+
sm: "320px",
|
|
41
|
+
md: "400px",
|
|
42
|
+
lg: "560px",
|
|
43
|
+
xl: "720px",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const isHorizontal = $derived(side === "left" || side === "right");
|
|
47
|
+
|
|
48
|
+
const panelStyle = $derived(
|
|
49
|
+
isHorizontal
|
|
50
|
+
? `width: ${sizeValues[size]}; max-width: 100vw;`
|
|
51
|
+
: `height: ${sizeValues[size]}; max-height: 100vh;`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const positionClasses: Record<Side, string> = {
|
|
55
|
+
left: "inset-y-0 left-0",
|
|
56
|
+
right: "inset-y-0 right-0",
|
|
57
|
+
top: "inset-x-0 top-0",
|
|
58
|
+
bottom: "inset-x-0 bottom-0",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const flyParams: Record<Side, { x?: number; y?: number }> = {
|
|
62
|
+
left: { x: -320 },
|
|
63
|
+
right: { x: 320 },
|
|
64
|
+
top: { y: -320 },
|
|
65
|
+
bottom: { y: 320 },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function handleClose() {
|
|
69
|
+
open = false;
|
|
70
|
+
onClose?.();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
74
|
+
if (e.key === "Escape" && closable) {
|
|
75
|
+
handleClose();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
81
|
+
|
|
82
|
+
{#if open}
|
|
83
|
+
<div use:portal class="fixed inset-0 z-50">
|
|
84
|
+
{#if closable}
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
|
|
88
|
+
aria-label="Close drawer"
|
|
89
|
+
tabindex="-1"
|
|
90
|
+
onclick={handleClose}
|
|
91
|
+
transition:fly={{ duration: 150 }}
|
|
92
|
+
></button>
|
|
93
|
+
{:else}
|
|
94
|
+
<div
|
|
95
|
+
class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
|
|
96
|
+
transition:fly={{ duration: 150 }}
|
|
97
|
+
></div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
<div
|
|
101
|
+
class="
|
|
102
|
+
absolute z-10 flex flex-col
|
|
103
|
+
bg-surface-overlay shadow-xl
|
|
104
|
+
{positionClasses[side]}
|
|
105
|
+
{className}
|
|
106
|
+
"
|
|
107
|
+
style={panelStyle}
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
aria-labelledby={title ? "drawer-title" : undefined}
|
|
111
|
+
transition:fly={{ ...flyParams[side], duration: 250 }}
|
|
112
|
+
>
|
|
113
|
+
<!-- Header -->
|
|
114
|
+
{#if header}
|
|
115
|
+
<div class="border-b border-outline-faint px-6 py-4">
|
|
116
|
+
{@render header()}
|
|
117
|
+
</div>
|
|
118
|
+
{:else if title || closable}
|
|
119
|
+
<div
|
|
120
|
+
class="flex items-start justify-between gap-4 border-b border-outline-faint px-6 py-4"
|
|
121
|
+
>
|
|
122
|
+
<div>
|
|
123
|
+
{#if title}
|
|
124
|
+
<h2 id="drawer-title" class="text-lg font-semibold text-on-surface">
|
|
125
|
+
{title}
|
|
126
|
+
</h2>
|
|
127
|
+
{/if}
|
|
128
|
+
</div>
|
|
129
|
+
{#if closable}
|
|
130
|
+
<button
|
|
131
|
+
onclick={handleClose}
|
|
132
|
+
class="-m-2 shrink-0 rounded-lg p-2 text-on-surface-faint transition-colors hover:bg-surface-sunken hover:text-on-surface-muted"
|
|
133
|
+
aria-label="Close"
|
|
134
|
+
>
|
|
135
|
+
<X size={20} />
|
|
136
|
+
</button>
|
|
137
|
+
{/if}
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
<!-- Body -->
|
|
142
|
+
{#if children}
|
|
143
|
+
<div class="flex-1 overflow-y-auto px-6 py-4">
|
|
144
|
+
{@render children()}
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
147
|
+
|
|
148
|
+
<!-- Footer -->
|
|
149
|
+
{#if footer}
|
|
150
|
+
<div class="border-t border-outline-faint bg-surface-sunken/50 px-6 py-4">
|
|
151
|
+
{@render footer()}
|
|
152
|
+
</div>
|
|
153
|
+
{/if}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
{/if}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import type { Component } from "svelte";
|
|
7
|
+
import { tick, onDestroy } from "svelte";
|
|
8
|
+
import { EllipsisVertical } from "@lucide/svelte";
|
|
9
|
+
import { scale } from "svelte/transition";
|
|
10
|
+
import { quintOut } from "svelte/easing";
|
|
11
|
+
import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
|
|
12
|
+
|
|
13
|
+
interface MenuItem {
|
|
14
|
+
label?: string;
|
|
15
|
+
icon?: Component;
|
|
16
|
+
href?: string;
|
|
17
|
+
onClick?: () => void;
|
|
18
|
+
danger?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
divider?: boolean;
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
items?: MenuItem[];
|
|
26
|
+
trigger?: Snippet;
|
|
27
|
+
children?: Snippet;
|
|
28
|
+
align?: "left" | "right";
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let { items = [], trigger, children, align = "right", class: className = "" }: Props = $props();
|
|
33
|
+
|
|
34
|
+
let open = $state(false);
|
|
35
|
+
let triggerEl = $state<HTMLElement | null>(null);
|
|
36
|
+
let menuEl = $state<HTMLElement | null>(null);
|
|
37
|
+
let cleanupFloating = $state<(() => void) | null>(null);
|
|
38
|
+
let transformOrigin = $state("top right");
|
|
39
|
+
|
|
40
|
+
import { portal } from "../actions/portal.js";
|
|
41
|
+
|
|
42
|
+
async function setupFloating() {
|
|
43
|
+
if (!triggerEl || !menuEl) return;
|
|
44
|
+
|
|
45
|
+
cleanupFloating = autoUpdate(triggerEl, menuEl, () => {
|
|
46
|
+
if (!triggerEl || !menuEl) return;
|
|
47
|
+
|
|
48
|
+
const placement = align === "right" ? "bottom-end" : "bottom-start";
|
|
49
|
+
|
|
50
|
+
void computePosition(triggerEl, menuEl, {
|
|
51
|
+
placement,
|
|
52
|
+
middleware: [
|
|
53
|
+
offset(4), // MENU_OFFSET
|
|
54
|
+
flip(),
|
|
55
|
+
shift({ padding: 8 }), // VIEWPORT_PADDING
|
|
56
|
+
],
|
|
57
|
+
}).then(({ x, y, placement }) => {
|
|
58
|
+
if (menuEl) {
|
|
59
|
+
Object.assign(menuEl.style, {
|
|
60
|
+
left: `${x}px`,
|
|
61
|
+
top: `${y}px`,
|
|
62
|
+
position: "absolute",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Simple origin logic based on verify placement
|
|
66
|
+
const isTop = placement.startsWith("top");
|
|
67
|
+
transformOrigin = `${isTop ? "bottom" : "top"} ${align}`;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleItemClick(item: MenuItem) {
|
|
74
|
+
if (item.divider) return;
|
|
75
|
+
if (item.disabled) return;
|
|
76
|
+
item.onClick?.();
|
|
77
|
+
toggle(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toggle(value: boolean) {
|
|
81
|
+
open = value;
|
|
82
|
+
if (open) {
|
|
83
|
+
void tick().then(() => {
|
|
84
|
+
void setupFloating();
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
cleanupFloating?.();
|
|
88
|
+
cleanupFloating = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleOutsideClick(e: MouseEvent) {
|
|
93
|
+
const target = e.target as HTMLElement;
|
|
94
|
+
if (triggerEl?.contains(target)) return;
|
|
95
|
+
if (menuEl?.contains(target)) return;
|
|
96
|
+
if (open) toggle(false);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
100
|
+
if (e.key === "Escape" && open) {
|
|
101
|
+
toggle(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onDestroy(() => {
|
|
106
|
+
cleanupFloating?.();
|
|
107
|
+
});
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<svelte:window onclick={handleOutsideClick} onkeydown={handleKeyDown} />
|
|
111
|
+
|
|
112
|
+
<div class="dropdown-container relative inline-block {className}">
|
|
113
|
+
<button
|
|
114
|
+
onclick={(e) => {
|
|
115
|
+
e.stopPropagation();
|
|
116
|
+
toggle(!open);
|
|
117
|
+
}}
|
|
118
|
+
bind:this={triggerEl}
|
|
119
|
+
aria-label="Options"
|
|
120
|
+
class="rounded-lg p-2 text-on-surface-muted transition-colors hover:bg-surface-sunken hover:text-on-surface focus:ring-2 focus:ring-outline focus:outline-none active:bg-surface-sunken"
|
|
121
|
+
>
|
|
122
|
+
{#if trigger}
|
|
123
|
+
{@render trigger()}
|
|
124
|
+
{:else}
|
|
125
|
+
<EllipsisVertical size={18} />
|
|
126
|
+
{/if}
|
|
127
|
+
</button>
|
|
128
|
+
|
|
129
|
+
{#if open}
|
|
130
|
+
<div
|
|
131
|
+
use:portal
|
|
132
|
+
bind:this={menuEl}
|
|
133
|
+
role="menu"
|
|
134
|
+
transition:scale={{ duration: 150, start: 0.95, easing: quintOut }}
|
|
135
|
+
class="
|
|
136
|
+
z-[9999]
|
|
137
|
+
min-w-48 overflow-y-auto
|
|
138
|
+
rounded-xl
|
|
139
|
+
border border-outline bg-surface-overlay py-1 shadow-xl shadow-on-surface/5
|
|
140
|
+
"
|
|
141
|
+
style="transform-origin: {transformOrigin};"
|
|
142
|
+
>
|
|
143
|
+
{#if children}
|
|
144
|
+
{@render children()}
|
|
145
|
+
{:else}
|
|
146
|
+
{#each items as item, idx (idx)}
|
|
147
|
+
{#if item.divider}
|
|
148
|
+
<hr class="my-1 border-outline-faint" />
|
|
149
|
+
{:else if item.href}
|
|
150
|
+
<a
|
|
151
|
+
href={item.href}
|
|
152
|
+
title={item.title}
|
|
153
|
+
role="menuitem"
|
|
154
|
+
class="
|
|
155
|
+
mx-1 flex items-center gap-3 rounded-lg px-3 py-2 text-sm
|
|
156
|
+
{item.danger
|
|
157
|
+
? 'text-danger-soft-text hover:bg-danger-soft'
|
|
158
|
+
: 'text-on-surface-muted hover:bg-surface-sunken'}
|
|
159
|
+
{item.disabled ? 'cursor-not-allowed opacity-50' : ''}
|
|
160
|
+
transition-colors
|
|
161
|
+
"
|
|
162
|
+
>
|
|
163
|
+
{#if item.icon}
|
|
164
|
+
{@const Icon = item.icon}
|
|
165
|
+
<Icon size={16} class="opacity-70" />
|
|
166
|
+
{/if}
|
|
167
|
+
{item.label}
|
|
168
|
+
</a>
|
|
169
|
+
{:else}
|
|
170
|
+
<button
|
|
171
|
+
onclick={() => handleItemClick(item)}
|
|
172
|
+
disabled={item.disabled}
|
|
173
|
+
title={item.title}
|
|
174
|
+
role="menuitem"
|
|
175
|
+
class="
|
|
176
|
+
mx-1 box-border flex w-full max-w-[calc(100%-8px)] items-center gap-3 rounded-lg px-3 py-2 text-left text-sm
|
|
177
|
+
{item.danger
|
|
178
|
+
? 'text-danger-soft-text hover:bg-danger-soft'
|
|
179
|
+
: 'text-on-surface-muted hover:bg-surface-sunken'}
|
|
180
|
+
transition-colors disabled:cursor-not-allowed
|
|
181
|
+
disabled:opacity-50
|
|
182
|
+
"
|
|
183
|
+
>
|
|
184
|
+
{#if item.icon}
|
|
185
|
+
{@const Icon = item.icon}
|
|
186
|
+
<Icon size={16} class="opacity-70" />
|
|
187
|
+
{/if}
|
|
188
|
+
{item.label}
|
|
189
|
+
</button>
|
|
190
|
+
{/if}
|
|
191
|
+
{/each}
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
194
|
+
{/if}
|
|
195
|
+
</div>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { CircleAlert, Clock } from "@lucide/svelte";
|
|
6
|
+
import ms, { type StringValue } from "ms";
|
|
7
|
+
|
|
8
|
+
type InputSize = "sm" | "md" | "lg";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
id?: string;
|
|
12
|
+
value?: number | null;
|
|
13
|
+
label?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
size?: InputSize;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
id,
|
|
24
|
+
value = $bindable(),
|
|
25
|
+
label,
|
|
26
|
+
hint,
|
|
27
|
+
error = $bindable(),
|
|
28
|
+
disabled = false,
|
|
29
|
+
class: className = "",
|
|
30
|
+
placeholder = "e.g. 5m 30s",
|
|
31
|
+
size = "md",
|
|
32
|
+
...restProps
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
35
|
+
const generatedId = `duration-input-${Math.random().toString(36).slice(2, 10)}`;
|
|
36
|
+
const inputId = $derived(id ?? generatedId);
|
|
37
|
+
|
|
38
|
+
let textValue = $state("");
|
|
39
|
+
let parsedHuman = $state("");
|
|
40
|
+
let isFocused = $state(false);
|
|
41
|
+
|
|
42
|
+
const sizeClasses: Record<InputSize, string> = {
|
|
43
|
+
sm: "text-sm px-3 py-1.5",
|
|
44
|
+
md: "text-sm px-3.5 py-2",
|
|
45
|
+
lg: "text-base px-4 py-2.5",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const inputClasses = `
|
|
49
|
+
w-full rounded-lg border
|
|
50
|
+
bg-surface-raised text-on-surface placeholder:text-on-surface-faint
|
|
51
|
+
focus:outline-none focus:ring-4 focus:ring-ring/10 focus:border-ring
|
|
52
|
+
disabled:bg-surface-sunken disabled:text-on-surface-muted disabled:cursor-not-allowed
|
|
53
|
+
transition-all duration-normal ease-in-out
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const normalBorder = "border-outline hover:border-outline-hover shadow-sm";
|
|
57
|
+
const errorBorder = "border-danger-400 focus:border-danger-500 focus:ring-danger-500/20";
|
|
58
|
+
|
|
59
|
+
function formatMs(msValue: number): string {
|
|
60
|
+
if (typeof msValue !== "number" || isNaN(msValue)) return "";
|
|
61
|
+
try {
|
|
62
|
+
return ms(msValue);
|
|
63
|
+
} catch {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseToMs(str: string): number | null {
|
|
69
|
+
if (!str || !str.trim()) return null;
|
|
70
|
+
try {
|
|
71
|
+
const result = ms(str as StringValue);
|
|
72
|
+
return typeof result === "number" ? result : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateFromValue() {
|
|
79
|
+
if (!isFocused) {
|
|
80
|
+
textValue = value != null ? formatMs(value) : "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
updateFromValue();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function handleInput(e: Event) {
|
|
89
|
+
const val = (e.target as HTMLInputElement).value;
|
|
90
|
+
textValue = val;
|
|
91
|
+
|
|
92
|
+
const msValue = parseToMs(val);
|
|
93
|
+
value = msValue;
|
|
94
|
+
|
|
95
|
+
if (typeof msValue === "number") {
|
|
96
|
+
parsedHuman = ms(msValue, { long: true });
|
|
97
|
+
if (error) error = undefined;
|
|
98
|
+
} else {
|
|
99
|
+
parsedHuman = "";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handleFocus() {
|
|
104
|
+
isFocused = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handleBlur() {
|
|
108
|
+
isFocused = false;
|
|
109
|
+
textValue = value != null ? formatMs(value) : "";
|
|
110
|
+
parsedHuman = "";
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<div class="space-y-1.5 {className}">
|
|
115
|
+
{#if label}
|
|
116
|
+
<label for={inputId} class="block text-sm font-medium text-on-surface-muted">
|
|
117
|
+
{label}
|
|
118
|
+
</label>
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
<div class="group relative">
|
|
122
|
+
<div
|
|
123
|
+
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-on-surface-faint transition-colors group-focus-within:text-primary"
|
|
124
|
+
>
|
|
125
|
+
<Clock size={16} />
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<input
|
|
129
|
+
id={inputId}
|
|
130
|
+
type="text"
|
|
131
|
+
class="
|
|
132
|
+
{inputClasses}
|
|
133
|
+
{sizeClasses[size]}
|
|
134
|
+
{error ? errorBorder : normalBorder}
|
|
135
|
+
pl-10
|
|
136
|
+
{error ? 'pr-10' : ''}
|
|
137
|
+
"
|
|
138
|
+
{disabled}
|
|
139
|
+
value={textValue}
|
|
140
|
+
{placeholder}
|
|
141
|
+
oninput={handleInput}
|
|
142
|
+
onfocus={handleFocus}
|
|
143
|
+
onblur={handleBlur}
|
|
144
|
+
aria-invalid={error ? "true" : undefined}
|
|
145
|
+
{...restProps}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
{#if error}
|
|
149
|
+
<div
|
|
150
|
+
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-danger-500"
|
|
151
|
+
>
|
|
152
|
+
<CircleAlert size={18} />
|
|
153
|
+
</div>
|
|
154
|
+
{/if}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{#if parsedHuman && isFocused}
|
|
158
|
+
<p
|
|
159
|
+
class="animate-in slide-in-from-top-1 fade-in text-xs font-medium text-primary duration-200"
|
|
160
|
+
>
|
|
161
|
+
{parsedHuman}
|
|
162
|
+
</p>
|
|
163
|
+
{:else if error}
|
|
164
|
+
<p class="animate-in slide-in-from-top-1 fade-in text-sm text-danger-600 duration-200">
|
|
165
|
+
{error}
|
|
166
|
+
</p>
|
|
167
|
+
{:else if hint}
|
|
168
|
+
<p class="text-sm text-on-surface-muted">{hint}</p>
|
|
169
|
+
{/if}
|
|
170
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import type { Component } from "svelte";
|
|
7
|
+
import { Inbox } from "@lucide/svelte";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
icon?: Component;
|
|
13
|
+
iconSize?: number;
|
|
14
|
+
actions?: Snippet;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
title = "No data",
|
|
20
|
+
description,
|
|
21
|
+
icon = Inbox,
|
|
22
|
+
iconSize = 48,
|
|
23
|
+
actions,
|
|
24
|
+
class: className = "",
|
|
25
|
+
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
const Icon = $derived(icon);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="flex flex-col items-center justify-center px-4 py-12 text-center {className}">
|
|
31
|
+
<div class="mb-4 rounded-full bg-surface-sunken p-4">
|
|
32
|
+
<Icon size={iconSize} class="text-on-surface-faint" />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<h3 class="mb-1 text-lg font-semibold text-on-surface">{title}</h3>
|
|
36
|
+
|
|
37
|
+
{#if description}
|
|
38
|
+
<p class="mb-6 max-w-sm text-sm text-on-surface-muted">{description}</p>
|
|
39
|
+
{/if}
|
|
40
|
+
|
|
41
|
+
{#if actions}
|
|
42
|
+
<div class="flex items-center gap-3">
|
|
43
|
+
{@render actions()}
|
|
44
|
+
</div>
|
|
45
|
+
{/if}
|
|
46
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Snippet, Component } from "svelte";
|
|
6
|
+
import { TriangleAlert, RefreshCw, WifiOff } from "@lucide/svelte";
|
|
7
|
+
import Button from "./Button.svelte";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Error message to display */
|
|
11
|
+
message: string;
|
|
12
|
+
/** Title override */
|
|
13
|
+
title?: string;
|
|
14
|
+
/** Icon override */
|
|
15
|
+
icon?: Component;
|
|
16
|
+
/** Callback when the retry button is clicked */
|
|
17
|
+
onRetry?: () => void;
|
|
18
|
+
/** Whether a retry is in progress */
|
|
19
|
+
retrying?: boolean;
|
|
20
|
+
/** Visual variant */
|
|
21
|
+
variant?: "default" | "disconnected";
|
|
22
|
+
/** Extra actions slot */
|
|
23
|
+
actions?: Snippet;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
message,
|
|
29
|
+
title,
|
|
30
|
+
icon,
|
|
31
|
+
onRetry,
|
|
32
|
+
retrying = false,
|
|
33
|
+
variant = "default",
|
|
34
|
+
actions,
|
|
35
|
+
class: className = "",
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
const defaultTitle = $derived(
|
|
39
|
+
title ?? (variant === "disconnected" ? "Connection Lost" : "Something went wrong"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const IconComponent = $derived(icon ?? (variant === "disconnected" ? WifiOff : TriangleAlert));
|
|
43
|
+
|
|
44
|
+
const iconBg = $derived(variant === "disconnected" ? "bg-danger-soft" : "bg-warning-soft");
|
|
45
|
+
const iconColor = $derived(
|
|
46
|
+
variant === "disconnected" ? "text-danger-surface" : "text-warning-surface",
|
|
47
|
+
);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<div
|
|
51
|
+
class="flex flex-col items-center justify-center gap-4 rounded-xl border border-outline bg-surface-raised px-6 py-12 text-center {className}"
|
|
52
|
+
role="alert"
|
|
53
|
+
>
|
|
54
|
+
<div class="rounded-full {iconBg} p-3">
|
|
55
|
+
<IconComponent class="h-8 w-8 {iconColor}" />
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="space-y-1">
|
|
59
|
+
<h3 class="text-lg font-semibold text-on-surface">{defaultTitle}</h3>
|
|
60
|
+
<p class="max-w-sm text-sm text-on-surface-muted">{message}</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{#if onRetry}
|
|
64
|
+
<Button variant="secondary" onclick={onRetry} loading={retrying}>
|
|
65
|
+
{#snippet icon()}<RefreshCw size={16} />{/snippet}
|
|
66
|
+
Retry
|
|
67
|
+
</Button>
|
|
68
|
+
{/if}
|
|
69
|
+
|
|
70
|
+
{#if actions}
|
|
71
|
+
{@render actions()}
|
|
72
|
+
{/if}
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Component, Snippet } from "svelte";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
title: string;
|
|
9
|
+
icon?: Component;
|
|
10
|
+
children?: Snippet;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { title, icon: Icon, children, class: className = "" }: Props = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<article
|
|
18
|
+
class="overflow-hidden rounded-xl border border-outline bg-surface-raised p-6 shadow-sm transition-colors hover:border-outline-hover {className}"
|
|
19
|
+
>
|
|
20
|
+
<div class="flex flex-col gap-3">
|
|
21
|
+
{#if Icon}
|
|
22
|
+
<span
|
|
23
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary-soft text-primary-soft-text"
|
|
24
|
+
>
|
|
25
|
+
<Icon size={20} />
|
|
26
|
+
</span>
|
|
27
|
+
{/if}
|
|
28
|
+
<h3 class="text-lg font-semibold text-on-surface">{title}</h3>
|
|
29
|
+
{#if children}
|
|
30
|
+
<div class="text-sm leading-relaxed text-on-surface-muted">
|
|
31
|
+
{@render children()}
|
|
32
|
+
</div>
|
|
33
|
+
{/if}
|
|
34
|
+
</div>
|
|
35
|
+
</article>
|