@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.
Files changed (73) hide show
  1. package/package.json +5 -6
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/portal.ts +5 -1
  4. package/src/lib/components/Accordion.svelte +6 -2
  5. package/src/lib/components/AccordionItem.svelte +80 -23
  6. package/src/lib/components/Avatar.svelte +0 -1
  7. package/src/lib/components/CodeBlock.svelte +84 -35
  8. package/src/lib/components/CronInput.svelte +5 -2
  9. package/src/lib/components/DataGrid.svelte +95 -133
  10. package/src/lib/components/Drawer.svelte +2 -6
  11. package/src/lib/components/Dropdown.svelte +19 -57
  12. package/src/lib/components/EmptyState.svelte +2 -1
  13. package/src/lib/components/ErrorState.svelte +2 -1
  14. package/src/lib/components/Heading.svelte +19 -12
  15. package/src/lib/components/Kbd.svelte +25 -10
  16. package/src/lib/components/Link.svelte +7 -1
  17. package/src/lib/components/LogConsole.svelte +37 -34
  18. package/src/lib/components/Modal.svelte +44 -33
  19. package/src/lib/components/NotificationBell.svelte +2 -1
  20. package/src/lib/components/PageContainer.svelte +1 -10
  21. package/src/lib/components/PageHeader.svelte +8 -4
  22. package/src/lib/components/Popover.svelte +9 -51
  23. package/src/lib/components/Progress.svelte +19 -6
  24. package/src/lib/components/Radio.svelte +16 -4
  25. package/src/lib/components/RadioGroup.svelte +15 -2
  26. package/src/lib/components/Select.svelte +13 -45
  27. package/src/lib/components/Skeleton.svelte +6 -6
  28. package/src/lib/components/Table.svelte +13 -4
  29. package/src/lib/components/TableBody.svelte +2 -1
  30. package/src/lib/components/TableCell.svelte +38 -8
  31. package/src/lib/components/TableHead.svelte +12 -4
  32. package/src/lib/components/TableRow.svelte +23 -3
  33. package/src/lib/components/TaskCard.svelte +1 -1
  34. package/src/lib/components/TimezoneSelect.svelte +2 -2
  35. package/src/lib/components/accordion.svelte.ts +19 -0
  36. package/src/lib/components/dashboard/RunDetailPanel.svelte +141 -67
  37. package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
  38. package/src/lib/components/dashboard/RunsList.svelte +57 -37
  39. package/src/lib/components/dashboard/cron-format.ts +1 -1
  40. package/src/lib/components/dashboard/run-filters.test.ts +2 -58
  41. package/src/lib/components/dashboard/run-filters.ts +5 -53
  42. package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
  43. package/src/lib/components/dashboard/run-helpers.ts +12 -1
  44. package/src/lib/components/dashboard/status-config.ts +38 -83
  45. package/src/lib/components/dashboard/types.ts +0 -26
  46. package/src/lib/components/radio-group.ts +11 -0
  47. package/src/lib/index.ts +8 -43
  48. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  49. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  50. package/src/lib/log-console/highlight-scroll.ts +41 -0
  51. package/src/lib/theme.css +10 -4
  52. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  53. package/src/lib/utils/clipboard.svelte.ts +73 -0
  54. package/src/lib/utils/format.ts +65 -94
  55. package/src/lib/utils/id.test.ts +3 -7
  56. package/src/lib/utils/id.ts +3 -2
  57. package/src/lib/utils/theme.svelte.test.ts +0 -6
  58. package/src/lib/utils/theme.svelte.ts +3 -5
  59. package/src/lib/utils/toast.svelte.ts +3 -11
  60. package/src/stories/Accordion.stories.svelte +61 -0
  61. package/src/stories/Button.stories.svelte +2 -2
  62. package/src/stories/CodeBlock.stories.svelte +55 -0
  63. package/src/stories/DataGrid.stories.svelte +2 -7
  64. package/src/stories/Dropdown.stories.svelte +3 -3
  65. package/src/stories/Heading.stories.svelte +31 -0
  66. package/src/stories/Kbd.stories.svelte +36 -0
  67. package/src/stories/Link.stories.svelte +38 -0
  68. package/src/stories/PageHeader.stories.svelte +31 -0
  69. package/src/stories/Progress.stories.svelte +26 -0
  70. package/src/stories/RadioGroup.stories.svelte +43 -0
  71. package/src/stories/Table.stories.svelte +69 -0
  72. package/src/svelte-shims.d.ts +0 -5
  73. 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
- interface Props {
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.length > 0 ? (prefixSums[prefixSums.length - 1] ?? 0) : 0;
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.length > 0
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
- const cs = chunkSize;
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: when highlightLine flips to a non-null number,
537
- // scroll the viewport so that line is centred, and pulse a one-shot
538
- // flash class for ~1.5s. Cleaning up the timer on prop change keeps a
539
- // rapid sequence of jumps from leaking timers.
540
- $effect(() => {
541
- const target = highlightLine;
542
- if (target === null || target === undefined) {
543
- flashLine = null;
544
- return;
545
- }
546
- if (!containerEl) return;
547
- const targetY = lineTop(target) - containerHeight / 2 + lineHeight / 2;
548
- const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
549
- containerEl.scrollTo({ top: clamped, behavior: "smooth" });
550
- isAutoScroll = false;
551
- userScrolledUp = true;
552
- flashLine = target;
553
- if (flashTimer !== null) clearTimeout(flashTimer);
554
- flashTimer = setTimeout(() => {
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
- flashTimer = null;
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
- function handleKeydown(e: KeyboardEvent) {
53
- if (e.key === "Escape" && closable) {
54
- handleClose();
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
- <svelte:window onkeydown={handleKeydown} />
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
- <div
63
- use:portal
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
- z-50 flex items-center justify-center
67
- p-4
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
- {#if closable}
71
- <button
72
- type="button"
73
- class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
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
- <h2
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
- </div>
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
- <h2 class="font-mono text-sm font-semibold text-on-surface">{title}</h2>
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
- subtitle?: string;
10
- actions?: Snippet;
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
- <h1 class="font-mono text-2xl font-bold tracking-tight text-on-surface">{title}</h1>
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
- const path = e.composedPath();
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 class="w-full {sizeClasses[size]} overflow-hidden rounded-full bg-surface-sunken">
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
- variant
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: string;
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 isChecked = $derived(groupValue === value);
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={() => (groupValue = value)}
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 { Snippet } from "svelte";
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: _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",