@runwisp/ui 1.4.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 (70) hide show
  1. package/package.json +4 -5
  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 +30 -32
  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/Table.svelte +13 -4
  28. package/src/lib/components/TableBody.svelte +2 -1
  29. package/src/lib/components/TableCell.svelte +38 -8
  30. package/src/lib/components/TableHead.svelte +12 -4
  31. package/src/lib/components/TableRow.svelte +23 -3
  32. package/src/lib/components/TaskCard.svelte +1 -1
  33. package/src/lib/components/TimezoneSelect.svelte +2 -2
  34. package/src/lib/components/accordion.svelte.ts +19 -0
  35. package/src/lib/components/dashboard/RunDetailPanel.svelte +91 -63
  36. package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
  37. package/src/lib/components/dashboard/RunsList.svelte +9 -18
  38. package/src/lib/components/dashboard/cron-format.ts +1 -1
  39. package/src/lib/components/dashboard/run-filters.test.ts +2 -58
  40. package/src/lib/components/dashboard/run-filters.ts +5 -53
  41. package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
  42. package/src/lib/components/dashboard/run-helpers.ts +12 -1
  43. package/src/lib/components/dashboard/status-config.ts +38 -83
  44. package/src/lib/components/dashboard/types.ts +0 -26
  45. package/src/lib/components/radio-group.ts +11 -0
  46. package/src/lib/index.ts +8 -43
  47. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  48. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  49. package/src/lib/log-console/highlight-scroll.ts +41 -0
  50. package/src/lib/theme.css +10 -4
  51. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  52. package/src/lib/utils/clipboard.svelte.ts +73 -0
  53. package/src/lib/utils/format.ts +65 -94
  54. package/src/lib/utils/id.test.ts +3 -7
  55. package/src/lib/utils/id.ts +3 -2
  56. package/src/lib/utils/theme.svelte.test.ts +0 -6
  57. package/src/lib/utils/theme.svelte.ts +3 -5
  58. package/src/lib/utils/toast.svelte.ts +3 -11
  59. package/src/stories/Accordion.stories.svelte +61 -0
  60. package/src/stories/CodeBlock.stories.svelte +55 -0
  61. package/src/stories/DataGrid.stories.svelte +0 -5
  62. package/src/stories/Heading.stories.svelte +31 -0
  63. package/src/stories/Kbd.stories.svelte +36 -0
  64. package/src/stories/Link.stories.svelte +38 -0
  65. package/src/stories/PageHeader.stories.svelte +31 -0
  66. package/src/stories/Progress.stories.svelte +26 -0
  67. package/src/stories/RadioGroup.stories.svelte +43 -0
  68. package/src/stories/Table.stories.svelte +69 -0
  69. package/src/svelte-shims.d.ts +0 -5
  70. 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.
@@ -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).
@@ -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.length > 0 ? (prefixSums[prefixSums.length - 1] ?? 0) : 0;
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.length > 0
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;
@@ -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
- const cs = chunkSize;
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());
@@ -538,29 +533,32 @@
538
533
  scrollTop = 0;
539
534
  }
540
535
 
541
- // Search-hit deep-link: when highlightLine flips to a non-null number,
542
- // scroll the viewport so that line is centred, and pulse a one-shot
543
- // flash class for ~1.5s. Cleaning up the timer on prop change keeps a
544
- // rapid sequence of jumps from leaking timers.
545
- $effect(() => {
546
- const target = highlightLine;
547
- if (target === null || target === undefined) {
548
- flashLine = null;
549
- return;
550
- }
551
- if (!containerEl) return;
552
- const targetY = lineTop(target) - containerHeight / 2 + lineHeight / 2;
553
- const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
554
- containerEl.scrollTo({ top: clamped, behavior: "smooth" });
555
- isAutoScroll = false;
556
- userScrolledUp = true;
557
- flashLine = target;
558
- if (flashTimer !== null) clearTimeout(flashTimer);
559
- 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: () => {
560
558
  flashLine = null;
561
- flashTimer = null;
562
- }, 1500);
559
+ },
563
560
  });
561
+ $effect(syncHighlightScroll);
564
562
 
565
563
  $effect(() => {
566
564
  if (!containerEl) return;
@@ -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",