@runwisp/ui 1.4.0 → 1.5.1
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 +4 -5
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/focusTrap.ts +3 -3
- 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/Badge.svelte +1 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +100 -138
- 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/FilterBar.svelte +1 -1
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -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 +40 -42
- 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/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +16 -47
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/StatusDot.svelte +1 -1
- 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/Tooltip.svelte +1 -1
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
- package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
- package/src/lib/components/dashboard/RunsList.svelte +17 -26
- package/src/lib/components/dashboard/cron-format.test.ts +1 -1
- package/src/lib/components/dashboard/cron-format.ts +3 -3
- package/src/lib/components/dashboard/run-filters.test.ts +4 -60
- package/src/lib/components/dashboard/run-filters.ts +29 -144
- package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
- package/src/lib/components/dashboard/run-helpers.ts +40 -39
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/ansi.ts +1 -1
- 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-tokens.css +21 -21
- package/src/lib/theme.css +22 -16
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.test.ts +3 -3
- 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 +5 -14
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +0 -5
- 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/LogConsole.stories.svelte +2 -2
- 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
|
@@ -2,23 +2,34 @@
|
|
|
2
2
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
|
+
type KbdSize = "xs" | "sm";
|
|
6
|
+
/** `faint` sits quietly inside an input; `console` rides dark terminal chrome and inherits its text color. */
|
|
7
|
+
type KbdTone = "default" | "faint" | "console";
|
|
8
|
+
|
|
5
9
|
interface Props {
|
|
6
10
|
keys: string | string[];
|
|
11
|
+
size?: KbdSize;
|
|
12
|
+
tone?: KbdTone;
|
|
7
13
|
class?: string;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
|
-
let { keys, class: className = "" }: Props = $props();
|
|
16
|
+
let { keys, size = "sm", tone = "default", class: className = "" }: Props = $props();
|
|
11
17
|
|
|
12
18
|
const keyList = $derived(Array.isArray(keys) ? keys : [keys]);
|
|
13
19
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
min-w-[1.5em] px-1.5
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const sizeClasses: Record<KbdSize, string> = {
|
|
21
|
+
xs: "px-1.5 text-2xs",
|
|
22
|
+
sm: "min-w-[1.5em] px-1.5 text-xs",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Vertical padding lives with the tone: console keys sit inline in toolbar
|
|
26
|
+
// text and must not grow the row.
|
|
27
|
+
const toneClasses: Record<KbdTone, string> = {
|
|
28
|
+
default:
|
|
29
|
+
"py-0.5 font-medium bg-surface-sunken text-on-surface-muted border-outline shadow-sm",
|
|
30
|
+
faint: "py-0.5 font-medium bg-surface-raised text-on-surface-faint border-outline-faint shadow-sm",
|
|
31
|
+
console: "tracking-wide border-[var(--rw-con-gutter)]",
|
|
32
|
+
};
|
|
22
33
|
</script>
|
|
23
34
|
|
|
24
35
|
<span class="inline-flex items-center gap-1 {className}">
|
|
@@ -26,6 +37,10 @@
|
|
|
26
37
|
{#if i > 0}
|
|
27
38
|
<span class="text-xs text-on-surface-faint select-none">+</span>
|
|
28
39
|
{/if}
|
|
29
|
-
<kbd
|
|
40
|
+
<kbd
|
|
41
|
+
class="inline-flex items-center justify-center rounded-[3px] border font-mono {sizeClasses[
|
|
42
|
+
size
|
|
43
|
+
]} {toneClasses[tone]}">{key}</kbd
|
|
44
|
+
>
|
|
30
45
|
{/each}
|
|
31
46
|
</span>
|
|
@@ -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.
|
|
@@ -51,8 +50,6 @@
|
|
|
51
50
|
|
|
52
51
|
let {
|
|
53
52
|
fetchLogs,
|
|
54
|
-
chunkSize = 4096,
|
|
55
|
-
lineHeight = 20,
|
|
56
53
|
class: className = "",
|
|
57
54
|
highlightLine = null,
|
|
58
55
|
fetchLineHistory,
|
|
@@ -63,6 +60,8 @@
|
|
|
63
60
|
wrap = $bindable(false),
|
|
64
61
|
}: Props = $props();
|
|
65
62
|
|
|
63
|
+
const lineHeight = 20; // px per rendered row
|
|
64
|
+
|
|
66
65
|
// --- Frame-history inline expansion (single expansion at a time) ---
|
|
67
66
|
// expandedLine is the absolute line number whose history block is open;
|
|
68
67
|
// expandedFrames holds the fetched whole-region frames (null while loading).
|
|
@@ -149,7 +148,7 @@
|
|
|
149
148
|
// lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
|
|
150
149
|
// O(log n) even though rows are no longer uniform. A plain Map is
|
|
151
150
|
// deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
|
|
152
|
-
// on any change), not by per-key subscriptions
|
|
151
|
+
// on any change), not by per-key subscriptions, iterating a SvelteMap of
|
|
153
152
|
// tens of thousands of lines on every streamed line would be wasteful.
|
|
154
153
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
155
154
|
const rowCounts = new Map<number, number>();
|
|
@@ -176,7 +175,7 @@
|
|
|
176
175
|
// Measures wrapped row counts for exactly the lines in [min, max] and bumps
|
|
177
176
|
// rowCountVersion if anything changed. Called directly wherever new line
|
|
178
177
|
// text lands in the cache (streamed append, on-demand backfill), each of
|
|
179
|
-
// which already knows the touched range from LogCache's merge result
|
|
178
|
+
// which already knows the touched range from LogCache's merge result, so
|
|
180
179
|
// a single appended line costs O(1) here instead of re-walking the whole
|
|
181
180
|
// (up to 50k-line) cache. Plain function, not an $effect: reads/writes
|
|
182
181
|
// rowCounts and cache.lines by key, never by iterating the map.
|
|
@@ -202,7 +201,7 @@
|
|
|
202
201
|
// or the available column width changes (wrap width affects every line's
|
|
203
202
|
// row count, so there's no way to scope that to a range). Line
|
|
204
203
|
// appends/backfills are measured incrementally at their call sites via
|
|
205
|
-
// measureRange instead
|
|
204
|
+
// measureRange instead, reading `cache.lines` in this effect's tracked
|
|
206
205
|
// scope would resubscribe it to the SvelteMap's shared version signal and
|
|
207
206
|
// re-run this full O(n) walk on every single appended line. `untrack`
|
|
208
207
|
// (same pattern as the fetch/prune effects below) keeps that read out of
|
|
@@ -271,7 +270,7 @@
|
|
|
271
270
|
const i = lineNum - cache.firstAvailableLine;
|
|
272
271
|
if (i <= 0) return base;
|
|
273
272
|
if (i >= prefixSums.length) {
|
|
274
|
-
const last = prefixSums.
|
|
273
|
+
const last = prefixSums.at(-1) ?? 0;
|
|
275
274
|
return last * lineHeight + base;
|
|
276
275
|
}
|
|
277
276
|
return (prefixSums[i] ?? 0) * lineHeight + base;
|
|
@@ -345,11 +344,8 @@
|
|
|
345
344
|
let overlayRows = $derived(cache.overlayRows);
|
|
346
345
|
|
|
347
346
|
let totalHeight = $derived.by(() => {
|
|
348
|
-
const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
|
|
349
347
|
const linesHeight = wrap
|
|
350
|
-
? (prefixSums.
|
|
351
|
-
? (prefixSums[prefixSums.length - 1] ?? 0)
|
|
352
|
-
: Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
|
|
348
|
+
? (prefixSums.at(-1) ?? Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
|
|
353
349
|
lineHeight +
|
|
354
350
|
truncationBannerHeight
|
|
355
351
|
: (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
|
|
@@ -376,7 +372,7 @@
|
|
|
376
372
|
|
|
377
373
|
// Width of the virtual scroll surface: wide enough for the longest line
|
|
378
374
|
// seen so far, never narrower than the viewport (so short logs show no
|
|
379
|
-
// horizontal scrollbar). Wrapping pins the surface to the viewport
|
|
375
|
+
// horizontal scrollbar). Wrapping pins the surface to the viewport, there
|
|
380
376
|
// is no horizontal axis to scroll.
|
|
381
377
|
let surfaceWidth = $derived(
|
|
382
378
|
wrap
|
|
@@ -410,8 +406,7 @@
|
|
|
410
406
|
// viewport lands at the end of the log without racing the auto-scroll.
|
|
411
407
|
$effect(() => {
|
|
412
408
|
const fn = fetchLogs;
|
|
413
|
-
|
|
414
|
-
fetcher = new LogFetcher(cache, fn, cs, (min, max) => {
|
|
409
|
+
fetcher = new LogFetcher(cache, fn, (min, max) => {
|
|
415
410
|
measureRange(min, max);
|
|
416
411
|
if (isAutoScroll && !userScrolledUp) {
|
|
417
412
|
requestAnimationFrame(() => scrollToBottom());
|
|
@@ -434,14 +429,14 @@
|
|
|
434
429
|
}
|
|
435
430
|
});
|
|
436
431
|
|
|
437
|
-
// Prune stale cache entries
|
|
432
|
+
// Prune stale cache entries, separate from reads to avoid a read-write cycle on SvelteMap.
|
|
438
433
|
$effect(() => {
|
|
439
434
|
const vs = visibleStart;
|
|
440
435
|
const ve = visibleEnd;
|
|
441
436
|
untrack(() => cache.prune(vs, ve));
|
|
442
437
|
});
|
|
443
438
|
|
|
444
|
-
let prevTailRows = 0; // plain variable
|
|
439
|
+
let prevTailRows = 0; // plain variable, intentionally non-reactive
|
|
445
440
|
|
|
446
441
|
$effect(() => {
|
|
447
442
|
// Track committed lines AND live overlay rows so an animating region at
|
|
@@ -498,7 +493,7 @@
|
|
|
498
493
|
containerWidth = containerEl.clientWidth;
|
|
499
494
|
// Resync the reactive scroll position with the DOM. The browser
|
|
500
495
|
// clamps scrollTop when the viewport grows (e.g. maximizing the
|
|
501
|
-
// console), but no scroll event fires for that clamp
|
|
496
|
+
// console), but no scroll event fires for that clamp, so without
|
|
502
497
|
// this the virtualizer keeps a stale, too-large scrollTop and
|
|
503
498
|
// renders an empty window until the first manual scroll.
|
|
504
499
|
scrollTop = containerEl.scrollTop;
|
|
@@ -538,29 +533,32 @@
|
|
|
538
533
|
scrollTop = 0;
|
|
539
534
|
}
|
|
540
535
|
|
|
541
|
-
// Search-hit deep-link:
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
return;
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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: () => {
|
|
560
558
|
flashLine = null;
|
|
561
|
-
|
|
562
|
-
}, 1500);
|
|
559
|
+
},
|
|
563
560
|
});
|
|
561
|
+
$effect(syncHighlightScroll);
|
|
564
562
|
|
|
565
563
|
$effect(() => {
|
|
566
564
|
if (!containerEl) return;
|
|
@@ -651,7 +649,7 @@
|
|
|
651
649
|
: 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
|
|
652
650
|
title="{line.frameCount} earlier frame{line.frameCount === 1
|
|
653
651
|
? ''
|
|
654
|
-
: 's'}
|
|
652
|
+
: 's'}, click to {expandedLine === line.num
|
|
655
653
|
? 'hide'
|
|
656
654
|
: 'rewind'}"
|
|
657
655
|
aria-label="Toggle frame history for line {line.num + 1}"
|
|
@@ -911,7 +909,7 @@
|
|
|
911
909
|
border-top-color: color-mix(in srgb, var(--rw-term-warn) 30%, transparent);
|
|
912
910
|
}
|
|
913
911
|
|
|
914
|
-
/* Subtle row hover on the fixed-dark console surface
|
|
912
|
+
/* Subtle row hover on the fixed-dark console surface, a faint lift of the
|
|
915
913
|
console text colour, never a theme-flipping fill. */
|
|
916
914
|
.log-line:hover {
|
|
917
915
|
background-color: color-mix(in srgb, var(--rw-con-text) 4%, transparent);
|
|
@@ -941,7 +939,7 @@
|
|
|
941
939
|
|
|
942
940
|
/* Blinking teal block caret shown while a run is actively streaming. One
|
|
943
941
|
monospace cell wide so it reads as a terminal cursor parked at the write
|
|
944
|
-
position
|
|
942
|
+
position, inline after a still-appending line, or alone on the next. */
|
|
945
943
|
.stream-cursor {
|
|
946
944
|
display: inline-block;
|
|
947
945
|
width: 1ch;
|
|
@@ -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>
|