@runwisp/ui 1.3.0 → 1.5.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/package.json +5 -6
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/portal.ts +5 -1
- package/src/lib/components/Accordion.svelte +6 -2
- package/src/lib/components/AccordionItem.svelte +80 -23
- package/src/lib/components/Avatar.svelte +0 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +95 -133
- package/src/lib/components/Drawer.svelte +2 -6
- package/src/lib/components/Dropdown.svelte +19 -57
- package/src/lib/components/EmptyState.svelte +2 -1
- package/src/lib/components/ErrorState.svelte +2 -1
- package/src/lib/components/Heading.svelte +19 -12
- package/src/lib/components/Kbd.svelte +25 -10
- package/src/lib/components/Link.svelte +7 -1
- package/src/lib/components/LogConsole.svelte +37 -34
- package/src/lib/components/Modal.svelte +44 -33
- package/src/lib/components/NotificationBell.svelte +2 -1
- package/src/lib/components/PageContainer.svelte +1 -10
- package/src/lib/components/PageHeader.svelte +8 -4
- package/src/lib/components/Popover.svelte +9 -51
- package/src/lib/components/Progress.svelte +19 -6
- package/src/lib/components/Radio.svelte +16 -4
- package/src/lib/components/RadioGroup.svelte +15 -2
- package/src/lib/components/Select.svelte +13 -45
- package/src/lib/components/Skeleton.svelte +6 -6
- package/src/lib/components/Table.svelte +13 -4
- package/src/lib/components/TableBody.svelte +2 -1
- package/src/lib/components/TableCell.svelte +38 -8
- package/src/lib/components/TableHead.svelte +12 -4
- package/src/lib/components/TableRow.svelte +23 -3
- package/src/lib/components/TaskCard.svelte +1 -1
- package/src/lib/components/TimezoneSelect.svelte +2 -2
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +141 -67
- package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
- package/src/lib/components/dashboard/RunsList.svelte +57 -37
- package/src/lib/components/dashboard/cron-format.ts +1 -1
- package/src/lib/components/dashboard/run-filters.test.ts +2 -58
- package/src/lib/components/dashboard/run-filters.ts +5 -53
- package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
- package/src/lib/components/dashboard/run-helpers.ts +12 -1
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/highlight-scroll.test.ts +59 -0
- package/src/lib/log-console/highlight-scroll.ts +41 -0
- package/src/lib/theme.css +10 -4
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.ts +65 -94
- package/src/lib/utils/id.test.ts +3 -7
- package/src/lib/utils/id.ts +3 -2
- package/src/lib/utils/theme.svelte.test.ts +0 -6
- package/src/lib/utils/theme.svelte.ts +3 -5
- package/src/lib/utils/toast.svelte.ts +3 -11
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/Button.stories.svelte +2 -2
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +2 -7
- package/src/stories/Dropdown.stories.svelte +3 -3
- package/src/stories/Heading.stories.svelte +31 -0
- package/src/stories/Kbd.stories.svelte +36 -0
- package/src/stories/Link.stories.svelte +38 -0
- package/src/stories/PageHeader.stories.svelte +31 -0
- package/src/stories/Progress.stories.svelte +26 -0
- package/src/stories/RadioGroup.stories.svelte +43 -0
- package/src/stories/Table.stories.svelte +69 -0
- package/src/svelte-shims.d.ts +0 -5
- package/src/lib/components/dashboard/status-config.test.ts +0 -18
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
|
+
import type { HTMLAnchorAttributes } from "svelte/elements";
|
|
6
7
|
|
|
7
8
|
type LinkVariant = "default" | "muted" | "primary" | "danger";
|
|
8
9
|
type LinkUnderline = "always" | "hover" | "none";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// Use Link for off-site URLs (`external` opens a new tab with rel=noopener
|
|
12
|
+
// noreferrer). An in-app href must still come from SvelteKit's resolve():
|
|
13
|
+
// being a component, Link sits outside svelte/no-navigation-without-resolve.
|
|
14
|
+
interface Props extends Omit<HTMLAnchorAttributes, "href" | "children" | "class"> {
|
|
11
15
|
href: string;
|
|
12
16
|
variant?: LinkVariant;
|
|
13
17
|
external?: boolean;
|
|
@@ -23,6 +27,7 @@
|
|
|
23
27
|
underline = "hover",
|
|
24
28
|
children,
|
|
25
29
|
class: className = "",
|
|
30
|
+
...restProps
|
|
26
31
|
}: Props = $props();
|
|
27
32
|
|
|
28
33
|
const baseClasses = `
|
|
@@ -50,6 +55,7 @@
|
|
|
50
55
|
class="{baseClasses} {variantClasses[variant]} {underlineClasses[underline]} {className}"
|
|
51
56
|
target={external ? "_blank" : undefined}
|
|
52
57
|
rel={external ? "noopener noreferrer" : undefined}
|
|
58
|
+
{...restProps}
|
|
53
59
|
>
|
|
54
60
|
{@render children()}
|
|
55
61
|
</a>
|
|
@@ -6,13 +6,12 @@
|
|
|
6
6
|
import { ansiLineToHtml, visibleColumns } from "../log-console/ansi.js";
|
|
7
7
|
import { LogCache } from "../log-console/LogCache.svelte.js";
|
|
8
8
|
import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
|
|
9
|
+
import { createHighlightScroll } from "../log-console/highlight-scroll.js";
|
|
9
10
|
import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
|
|
10
11
|
import { formatBytes } from "../utils/format.js";
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
fetchLogs?: FetchLogsFn;
|
|
14
|
-
chunkSize?: number;
|
|
15
|
-
lineHeight?: number;
|
|
16
15
|
class?: string;
|
|
17
16
|
// highlightLine, when set, scrolls the console so the given absolute
|
|
18
17
|
// line number is visible and pulses a short-lived flash on that row.
|
|
@@ -37,6 +36,10 @@
|
|
|
37
36
|
// run that simply produced no output. Null/undefined renders nothing;
|
|
38
37
|
// the caller clears it once a subsequent fetch succeeds.
|
|
39
38
|
error?: string | null;
|
|
39
|
+
// The caller is still fetching this console's first output (e.g. the
|
|
40
|
+
// run panel seeding its tail). Shows the loading state rather than
|
|
41
|
+
// "No output yet" until that output arrives.
|
|
42
|
+
loading?: boolean;
|
|
40
43
|
// Wrap long lines instead of horizontally scrolling them. When on, each
|
|
41
44
|
// rendered row's height becomes a multiple of `lineHeight` (one per
|
|
42
45
|
// wrapped visual row), so the virtualizer switches from the fixed-height
|
|
@@ -47,17 +50,18 @@
|
|
|
47
50
|
|
|
48
51
|
let {
|
|
49
52
|
fetchLogs,
|
|
50
|
-
chunkSize = 4096,
|
|
51
|
-
lineHeight = 20,
|
|
52
53
|
class: className = "",
|
|
53
54
|
highlightLine = null,
|
|
54
55
|
fetchLineHistory,
|
|
55
56
|
endLabel = "end of output",
|
|
56
57
|
endTone = "muted",
|
|
57
58
|
error = null,
|
|
59
|
+
loading = false,
|
|
58
60
|
wrap = $bindable(false),
|
|
59
61
|
}: Props = $props();
|
|
60
62
|
|
|
63
|
+
const lineHeight = 20; // px per rendered row
|
|
64
|
+
|
|
61
65
|
// --- Frame-history inline expansion (single expansion at a time) ---
|
|
62
66
|
// expandedLine is the absolute line number whose history block is open;
|
|
63
67
|
// expandedFrames holds the fetched whole-region frames (null while loading).
|
|
@@ -266,7 +270,7 @@
|
|
|
266
270
|
const i = lineNum - cache.firstAvailableLine;
|
|
267
271
|
if (i <= 0) return base;
|
|
268
272
|
if (i >= prefixSums.length) {
|
|
269
|
-
const last = prefixSums.
|
|
273
|
+
const last = prefixSums.at(-1) ?? 0;
|
|
270
274
|
return last * lineHeight + base;
|
|
271
275
|
}
|
|
272
276
|
return (prefixSums[i] ?? 0) * lineHeight + base;
|
|
@@ -340,11 +344,8 @@
|
|
|
340
344
|
let overlayRows = $derived(cache.overlayRows);
|
|
341
345
|
|
|
342
346
|
let totalHeight = $derived.by(() => {
|
|
343
|
-
const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
|
|
344
347
|
const linesHeight = wrap
|
|
345
|
-
? (prefixSums.
|
|
346
|
-
? (prefixSums[prefixSums.length - 1] ?? 0)
|
|
347
|
-
: Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
|
|
348
|
+
? (prefixSums.at(-1) ?? Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
|
|
348
349
|
lineHeight +
|
|
349
350
|
truncationBannerHeight
|
|
350
351
|
: (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
|
|
@@ -405,8 +406,7 @@
|
|
|
405
406
|
// viewport lands at the end of the log without racing the auto-scroll.
|
|
406
407
|
$effect(() => {
|
|
407
408
|
const fn = fetchLogs;
|
|
408
|
-
|
|
409
|
-
fetcher = new LogFetcher(cache, fn, cs, (min, max) => {
|
|
409
|
+
fetcher = new LogFetcher(cache, fn, (min, max) => {
|
|
410
410
|
measureRange(min, max);
|
|
411
411
|
if (isAutoScroll && !userScrolledUp) {
|
|
412
412
|
requestAnimationFrame(() => scrollToBottom());
|
|
@@ -533,29 +533,32 @@
|
|
|
533
533
|
scrollTop = 0;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
-
// Search-hit deep-link:
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
return;
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
536
|
+
// Search-hit deep-link: scroll the viewport so the highlighted line is
|
|
537
|
+
// centred, once per highlightLine, and pulse a one-shot flash class for
|
|
538
|
+
// ~1.5s. createHighlightScroll keeps later layout changes (new lines on a
|
|
539
|
+
// live run, resizes) from pulling the view back to the hit.
|
|
540
|
+
const syncHighlightScroll = createHighlightScroll({
|
|
541
|
+
line: () => highlightLine,
|
|
542
|
+
ready: (line) => containerEl !== null && containerHeight > 0 && line < cache.totalLines,
|
|
543
|
+
reveal: (line) => {
|
|
544
|
+
if (!containerEl) return;
|
|
545
|
+
const targetY = lineTop(line) - containerHeight / 2 + lineHeight / 2;
|
|
546
|
+
const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
|
|
547
|
+
containerEl.scrollTo({ top: clamped, behavior: "smooth" });
|
|
548
|
+
isAutoScroll = false;
|
|
549
|
+
userScrolledUp = true;
|
|
550
|
+
flashLine = line;
|
|
551
|
+
if (flashTimer !== null) clearTimeout(flashTimer);
|
|
552
|
+
flashTimer = setTimeout(() => {
|
|
553
|
+
flashLine = null;
|
|
554
|
+
flashTimer = null;
|
|
555
|
+
}, 1500);
|
|
556
|
+
},
|
|
557
|
+
clear: () => {
|
|
555
558
|
flashLine = null;
|
|
556
|
-
|
|
557
|
-
}, 1500);
|
|
559
|
+
},
|
|
558
560
|
});
|
|
561
|
+
$effect(syncHighlightScroll);
|
|
559
562
|
|
|
560
563
|
$effect(() => {
|
|
561
564
|
if (!containerEl) return;
|
|
@@ -798,7 +801,7 @@
|
|
|
798
801
|
{/each}
|
|
799
802
|
{/if}
|
|
800
803
|
|
|
801
|
-
{#if cache.totalLines === 0 && overlayRows.length === 0 && !fetcher?.isFetching}
|
|
804
|
+
{#if cache.totalLines === 0 && overlayRows.length === 0 && !loading && !fetcher?.isFetching}
|
|
802
805
|
<div
|
|
803
806
|
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
804
807
|
>
|
|
@@ -809,7 +812,7 @@
|
|
|
809
812
|
</div>
|
|
810
813
|
{/if}
|
|
811
814
|
|
|
812
|
-
{#if cache.totalLines === 0 && fetcher?.isFetching}
|
|
815
|
+
{#if cache.totalLines === 0 && (loading || fetcher?.isFetching)}
|
|
813
816
|
<div
|
|
814
817
|
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
815
818
|
>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import { X } from "@lucide/svelte";
|
|
7
|
+
import Heading from "./Heading.svelte";
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
open?: boolean;
|
|
@@ -20,7 +21,6 @@
|
|
|
20
21
|
class?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
import { portal } from "../actions/portal.js";
|
|
24
24
|
import { trapFocus } from "../actions/focusTrap.js";
|
|
25
25
|
|
|
26
26
|
let {
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
class: className = "",
|
|
37
37
|
}: Props = $props();
|
|
38
38
|
|
|
39
|
+
const titleId = $props.id();
|
|
40
|
+
|
|
39
41
|
const sizeClasses: Record<string, string> = {
|
|
40
42
|
sm: "max-w-sm",
|
|
41
43
|
md: "max-w-md",
|
|
@@ -49,35 +51,52 @@
|
|
|
49
51
|
onClose?.();
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// showModal() puts the dialog in the top layer and makes the page behind it
|
|
55
|
+
// inert. The dialog stays open while mounted; `open` drives (un)mounting.
|
|
56
|
+
function showModal(node: HTMLDialogElement) {
|
|
57
|
+
node.showModal();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleCancel(e: Event) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
if (closable) handleClose();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Chrome ignores preventDefault() on a repeated Escape and closes anyway;
|
|
66
|
+
// reopen so a non-closable modal stays up while mounted.
|
|
67
|
+
function handleNativeClose(e: Event & { currentTarget: HTMLDialogElement }) {
|
|
68
|
+
if (open) e.currentTarget.showModal();
|
|
56
69
|
}
|
|
57
|
-
</script>
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
// Only a press that starts and ends on the backdrop closes the modal, so a
|
|
72
|
+
// text selection dragged out of the panel doesn't.
|
|
73
|
+
let pressedBackdrop = false;
|
|
74
|
+
function handlePointerDown(e: PointerEvent) {
|
|
75
|
+
pressedBackdrop = e.target === e.currentTarget;
|
|
76
|
+
}
|
|
77
|
+
function handleBackdropClick(e: MouseEvent) {
|
|
78
|
+
if (closable && pressedBackdrop && e.target === e.currentTarget) handleClose();
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
60
81
|
|
|
61
82
|
{#if open}
|
|
62
|
-
<
|
|
63
|
-
use:
|
|
83
|
+
<dialog
|
|
84
|
+
use:showModal
|
|
85
|
+
oncancel={handleCancel}
|
|
86
|
+
onclose={handleNativeClose}
|
|
87
|
+
onpointerdown={handlePointerDown}
|
|
88
|
+
onclick={handleBackdropClick}
|
|
89
|
+
aria-labelledby={title ? titleId : undefined}
|
|
64
90
|
class="
|
|
65
|
-
fixed inset-0
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
fixed inset-0 m-0 flex h-full max-h-none w-full max-w-none
|
|
92
|
+
items-center justify-center border-0 bg-transparent p-4
|
|
93
|
+
text-inherit backdrop:bg-transparent
|
|
68
94
|
"
|
|
69
95
|
>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
aria-label="Close modal"
|
|
75
|
-
tabindex="-1"
|
|
76
|
-
onclick={handleClose}
|
|
77
|
-
></button>
|
|
78
|
-
{:else}
|
|
79
|
-
<div class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"></div>
|
|
80
|
-
{/if}
|
|
96
|
+
<div
|
|
97
|
+
class="pointer-events-none absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
></div>
|
|
81
100
|
|
|
82
101
|
<div
|
|
83
102
|
use:trapFocus
|
|
@@ -87,9 +106,6 @@
|
|
|
87
106
|
rounded-[4px] border border-outline bg-surface-overlay shadow-lg
|
|
88
107
|
{className}
|
|
89
108
|
"
|
|
90
|
-
role="dialog"
|
|
91
|
-
aria-modal="true"
|
|
92
|
-
aria-labelledby={title ? "modal-title" : undefined}
|
|
93
109
|
>
|
|
94
110
|
{#if header}
|
|
95
111
|
<div class="border-b border-outline px-6 py-4">
|
|
@@ -101,12 +117,7 @@
|
|
|
101
117
|
>
|
|
102
118
|
<div>
|
|
103
119
|
{#if title}
|
|
104
|
-
<
|
|
105
|
-
id="modal-title"
|
|
106
|
-
class="font-mono text-lg font-semibold text-on-surface"
|
|
107
|
-
>
|
|
108
|
-
{title}
|
|
109
|
-
</h2>
|
|
120
|
+
<Heading level={2} size="lg" id={titleId}>{title}</Heading>
|
|
110
121
|
{/if}
|
|
111
122
|
{#if description}
|
|
112
123
|
<p class="mt-1 text-sm text-on-surface-muted">{description}</p>
|
|
@@ -136,5 +147,5 @@
|
|
|
136
147
|
</div>
|
|
137
148
|
{/if}
|
|
138
149
|
</div>
|
|
139
|
-
</
|
|
150
|
+
</dialog>
|
|
140
151
|
{/if}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<script lang="ts">
|
|
10
10
|
import type { Snippet } from "svelte";
|
|
11
11
|
import { Bell } from "@lucide/svelte";
|
|
12
|
+
import Heading from "./Heading.svelte";
|
|
12
13
|
import Popover from "./Popover.svelte";
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
|
|
61
62
|
<div role="dialog" aria-label={title} class="w-96 max-w-[90vw]">
|
|
62
63
|
<div class="mb-2 flex items-center justify-between gap-2">
|
|
63
|
-
<
|
|
64
|
+
<Heading level={2} size="sm">{title}</Heading>
|
|
64
65
|
{#if actions}
|
|
65
66
|
<div class="flex items-center gap-3 text-xs">{@render actions()}</div>
|
|
66
67
|
{/if}
|
|
@@ -4,16 +4,7 @@
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* PageContainer — the single, standardized page-level wrapper.
|
|
9
|
-
*
|
|
10
|
-
* Variants:
|
|
11
|
-
* "standard" – Max-width + centering + padding + flex-col + min-h-full.
|
|
12
|
-
* "full" – Same as standard but h-full (fills viewport).
|
|
13
|
-
* "wide" – Max-width + centering only (consumer provides padding).
|
|
14
|
-
* "narrow" – Narrower max-width + centering.
|
|
15
|
-
* "flush" – Fills container height, no max-width.
|
|
16
|
-
*/
|
|
7
|
+
/** The standard page-level wrapper. See variantClasses for each variant's layout. */
|
|
17
8
|
let {
|
|
18
9
|
children,
|
|
19
10
|
variant = "standard",
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
|
+
import Heading from "./Heading.svelte";
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
title: string;
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/** Plain text, or a snippet when the line carries tokens (counts, names). */
|
|
11
|
+
subtitle?: string | Snippet;
|
|
12
|
+
actions?: Snippet | undefined;
|
|
11
13
|
class?: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -16,8 +18,10 @@
|
|
|
16
18
|
|
|
17
19
|
<div class="flex shrink-0 flex-wrap items-start justify-between gap-3 px-1 {className}">
|
|
18
20
|
<div class="min-w-0">
|
|
19
|
-
<
|
|
20
|
-
{#if subtitle}
|
|
21
|
+
<Heading level={1} size="2xl">{title}</Heading>
|
|
22
|
+
{#if typeof subtitle === "function"}
|
|
23
|
+
<p class="mt-0.5 text-sm text-on-surface-muted">{@render subtitle()}</p>
|
|
24
|
+
{:else if subtitle}
|
|
21
25
|
<p class="mt-0.5 text-sm text-on-surface-muted">{subtitle}</p>
|
|
22
26
|
{/if}
|
|
23
27
|
</div>
|
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import type { Placement } from "@floating-ui/dom";
|
|
7
|
-
import { onDestroy } from "svelte";
|
|
8
|
-
import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
|
|
9
7
|
import { fade } from "svelte/transition";
|
|
8
|
+
import { floating, isOutsideClick } from "../actions/floating.js";
|
|
10
9
|
import { portal } from "../actions/portal.js";
|
|
11
10
|
import { trapFocus } from "../actions/focusTrap.js";
|
|
12
11
|
|
|
@@ -33,7 +32,6 @@
|
|
|
33
32
|
|
|
34
33
|
let triggerEl = $state<HTMLElement | null>(null);
|
|
35
34
|
let contentEl = $state<HTMLElement | null>(null);
|
|
36
|
-
let cleanupFloating = $state<(() => void) | null>(null);
|
|
37
35
|
|
|
38
36
|
// Track the `md` breakpoint only when sheet mode is requested, so the
|
|
39
37
|
// content can switch between floating (desktop) and bottom sheet (phone)
|
|
@@ -50,64 +48,17 @@
|
|
|
50
48
|
|
|
51
49
|
const asSheet = $derived(mobileSheet && !isDesktop);
|
|
52
50
|
|
|
53
|
-
function teardown() {
|
|
54
|
-
cleanupFloating?.();
|
|
55
|
-
cleanupFloating = null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function setupFloating() {
|
|
59
|
-
if (!triggerEl || !contentEl) return;
|
|
60
|
-
cleanupFloating = autoUpdate(triggerEl, contentEl, () => {
|
|
61
|
-
if (!triggerEl || !contentEl) return;
|
|
62
|
-
void computePosition(triggerEl, contentEl, {
|
|
63
|
-
placement,
|
|
64
|
-
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
|
65
|
-
}).then(({ x, y }) => {
|
|
66
|
-
if (contentEl) {
|
|
67
|
-
Object.assign(contentEl.style, {
|
|
68
|
-
left: `${String(x)}px`,
|
|
69
|
-
top: `${String(y)}px`,
|
|
70
|
-
position: "absolute",
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Position the content while open: float it on desktop, or pin it as a
|
|
78
|
-
// sheet on phone (clearing any inline coords floating-ui left behind).
|
|
79
|
-
$effect(() => {
|
|
80
|
-
if (!open || !contentEl) {
|
|
81
|
-
teardown();
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (asSheet) {
|
|
85
|
-
teardown();
|
|
86
|
-
contentEl.style.removeProperty("left");
|
|
87
|
-
contentEl.style.removeProperty("top");
|
|
88
|
-
contentEl.style.removeProperty("position");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
setupFloating();
|
|
92
|
-
return teardown;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
51
|
function toggle() {
|
|
96
52
|
open = !open;
|
|
97
53
|
}
|
|
98
54
|
|
|
99
55
|
function handleOutsideClick(e: MouseEvent) {
|
|
100
|
-
|
|
101
|
-
if (triggerEl && path.includes(triggerEl)) return;
|
|
102
|
-
if (contentEl && path.includes(contentEl)) return;
|
|
103
|
-
if (open) open = false;
|
|
56
|
+
if (open && isOutsideClick(e, triggerEl, contentEl)) open = false;
|
|
104
57
|
}
|
|
105
58
|
|
|
106
59
|
function handleKeyDown(e: KeyboardEvent) {
|
|
107
60
|
if (e.key === "Escape" && open) open = false;
|
|
108
61
|
}
|
|
109
|
-
|
|
110
|
-
onDestroy(teardown);
|
|
111
62
|
</script>
|
|
112
63
|
|
|
113
64
|
<svelte:window onclick={handleOutsideClick} onkeydown={handleKeyDown} />
|
|
@@ -146,6 +97,13 @@
|
|
|
146
97
|
<div
|
|
147
98
|
use:portal
|
|
148
99
|
use:trapFocus
|
|
100
|
+
use:floating={{
|
|
101
|
+
reference: triggerEl,
|
|
102
|
+
placement,
|
|
103
|
+
offset: 8,
|
|
104
|
+
padding: 8,
|
|
105
|
+
disabled: asSheet,
|
|
106
|
+
}}
|
|
149
107
|
bind:this={contentEl}
|
|
150
108
|
class="
|
|
151
109
|
z-[9999] max-h-[80vh] overflow-y-auto
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
size?: ProgressSize;
|
|
13
13
|
showLabel?: boolean;
|
|
14
14
|
label?: string;
|
|
15
|
+
/** Percent at which the bar turns warning / danger, overriding `variant`
|
|
16
|
+
* (e.g. `{ warning: 70, danger: 90 }` for CPU or memory). */
|
|
17
|
+
thresholds?: { warning?: number; danger?: number };
|
|
15
18
|
class?: string;
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -22,11 +25,18 @@
|
|
|
22
25
|
size = "md",
|
|
23
26
|
showLabel = false,
|
|
24
27
|
label,
|
|
28
|
+
thresholds,
|
|
25
29
|
class: className = "",
|
|
26
30
|
}: Props = $props();
|
|
27
31
|
|
|
28
32
|
const percentage = $derived(Math.min(Math.max((value / max) * 100, 0), 100));
|
|
29
33
|
|
|
34
|
+
const tone = $derived.by((): ProgressVariant => {
|
|
35
|
+
if (thresholds?.danger !== undefined && percentage >= thresholds.danger) return "danger";
|
|
36
|
+
if (thresholds?.warning !== undefined && percentage >= thresholds.warning) return "warning";
|
|
37
|
+
return variant;
|
|
38
|
+
});
|
|
39
|
+
|
|
30
40
|
const variantClasses: Record<ProgressVariant, string> = {
|
|
31
41
|
default: "bg-primary",
|
|
32
42
|
success: "bg-success-surface",
|
|
@@ -55,16 +65,19 @@
|
|
|
55
65
|
</div>
|
|
56
66
|
{/if}
|
|
57
67
|
|
|
58
|
-
<div
|
|
68
|
+
<div
|
|
69
|
+
class="w-full {sizeClasses[size]} overflow-hidden rounded-full bg-surface-sunken"
|
|
70
|
+
role="progressbar"
|
|
71
|
+
aria-label={label}
|
|
72
|
+
aria-valuenow={value}
|
|
73
|
+
aria-valuemin={0}
|
|
74
|
+
aria-valuemax={max}
|
|
75
|
+
>
|
|
59
76
|
<div
|
|
60
77
|
class="{sizeClasses[size]} {variantClasses[
|
|
61
|
-
|
|
78
|
+
tone
|
|
62
79
|
]} rounded-full transition-[width] duration-200"
|
|
63
80
|
style="width: {percentage}%"
|
|
64
|
-
role="progressbar"
|
|
65
|
-
aria-valuenow={value}
|
|
66
|
-
aria-valuemin={0}
|
|
67
|
-
aria-valuemax={max}
|
|
68
81
|
></div>
|
|
69
82
|
</div>
|
|
70
83
|
</div>
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
|
+
import { getContext } from "svelte";
|
|
6
|
+
import { RADIO_GROUP_KEY, type RadioGroupContext } from "./radio-group.js";
|
|
7
|
+
|
|
5
8
|
type RadioSize = "sm" | "md" | "lg";
|
|
6
9
|
|
|
7
10
|
interface Props {
|
|
8
11
|
value: string;
|
|
12
|
+
/** Selected value when used without a RadioGroup; inside one, the group owns it. */
|
|
9
13
|
groupValue?: string;
|
|
10
|
-
name
|
|
14
|
+
/** Defaults to the enclosing RadioGroup's name. */
|
|
15
|
+
name?: string;
|
|
11
16
|
label: string;
|
|
12
17
|
description?: string;
|
|
13
18
|
disabled?: boolean;
|
|
@@ -32,7 +37,14 @@
|
|
|
32
37
|
lg: { radio: "h-6 w-6", dot: "h-2.5 w-2.5", text: "text-base" },
|
|
33
38
|
};
|
|
34
39
|
|
|
35
|
-
const
|
|
40
|
+
const group = getContext<RadioGroupContext | undefined>(RADIO_GROUP_KEY);
|
|
41
|
+
|
|
42
|
+
const isChecked = $derived((group ? group.value : groupValue) === value);
|
|
43
|
+
|
|
44
|
+
function select() {
|
|
45
|
+
if (group) group.value = value;
|
|
46
|
+
else groupValue = value;
|
|
47
|
+
}
|
|
36
48
|
</script>
|
|
37
49
|
|
|
38
50
|
<label
|
|
@@ -43,11 +55,11 @@
|
|
|
43
55
|
<div class="relative flex shrink-0 items-center justify-center">
|
|
44
56
|
<input
|
|
45
57
|
type="radio"
|
|
46
|
-
{name}
|
|
58
|
+
name={name ?? group?.name}
|
|
47
59
|
{value}
|
|
48
60
|
{disabled}
|
|
49
61
|
checked={isChecked}
|
|
50
|
-
onchange={
|
|
62
|
+
onchange={select}
|
|
51
63
|
class="
|
|
52
64
|
peer {sizeClasses[size].radio}
|
|
53
65
|
cursor-pointer appearance-none rounded-full
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
|
-
import type
|
|
5
|
+
import { setContext, type Snippet } from "svelte";
|
|
6
|
+
import { RADIO_GROUP_KEY, type RadioGroupContext } from "./radio-group.js";
|
|
6
7
|
|
|
7
8
|
type Orientation = "horizontal" | "vertical";
|
|
8
9
|
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
|
|
20
21
|
let {
|
|
21
22
|
value = $bindable(""),
|
|
22
|
-
name
|
|
23
|
+
name,
|
|
23
24
|
label,
|
|
24
25
|
error,
|
|
25
26
|
hint,
|
|
@@ -28,6 +29,18 @@
|
|
|
28
29
|
class: className = "",
|
|
29
30
|
}: Props = $props();
|
|
30
31
|
|
|
32
|
+
setContext<RadioGroupContext>(RADIO_GROUP_KEY, {
|
|
33
|
+
get name() {
|
|
34
|
+
return name;
|
|
35
|
+
},
|
|
36
|
+
get value() {
|
|
37
|
+
return value;
|
|
38
|
+
},
|
|
39
|
+
set value(next: string) {
|
|
40
|
+
value = next;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
31
44
|
const orientationClasses: Record<Orientation, string> = {
|
|
32
45
|
horizontal: "flex flex-row flex-wrap gap-x-6 gap-y-3",
|
|
33
46
|
vertical: "flex flex-col gap-3",
|