@runwisp/ui 1.5.1 → 1.7.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 (53) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  43. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  44. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  45. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  46. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  47. package/src/lib/utils/format.test.ts +122 -0
  48. package/src/lib/utils/format.ts +97 -4
  49. package/src/lib/utils/typing-target.ts +10 -0
  50. package/src/stories/Accordion.stories.svelte +60 -1
  51. package/src/stories/CodeBlock.stories.svelte +13 -0
  52. package/src/stories/Link.stories.svelte +25 -1
  53. package/src/stories/Tabs.stories.svelte +17 -0
@@ -22,6 +22,7 @@
22
22
  }
23
23
 
24
24
  import { trapFocus } from "../actions/focusTrap.js";
25
+ import { modalDialogHandlers, showModal } from "../actions/modal-dialog.js";
25
26
 
26
27
  let {
27
28
  open = $bindable(false),
@@ -51,41 +52,17 @@
51
52
  onClose?.();
52
53
  }
53
54
 
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();
69
- }
70
-
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
- }
55
+ const dialogHandlers = modalDialogHandlers({
56
+ closable: () => closable,
57
+ isOpen: () => open,
58
+ close: handleClose,
59
+ });
80
60
  </script>
81
61
 
82
62
  {#if open}
83
63
  <dialog
84
64
  use:showModal
85
- oncancel={handleCancel}
86
- onclose={handleNativeClose}
87
- onpointerdown={handlePointerDown}
88
- onclick={handleBackdropClick}
65
+ {...dialogHandlers}
89
66
  aria-labelledby={title ? titleId : undefined}
90
67
  class="
91
68
  fixed inset-0 m-0 flex h-full max-h-none w-full max-w-none
@@ -5,8 +5,10 @@
5
5
  type SpinnerSize = "xs" | "sm" | "md" | "lg";
6
6
 
7
7
  interface Props {
8
- size?: SpinnerSize;
8
+ /** A preset, or an exact pixel size. */
9
+ size?: SpinnerSize | number;
9
10
  color?: string;
11
+ /** Screen-reader text. An empty label drops the status role, for spinners inside a labelled control. */
10
12
  label?: string;
11
13
  class?: string;
12
14
  }
@@ -26,12 +28,15 @@
26
28
  };
27
29
  </script>
28
30
 
29
- <div role="status" class="inline-flex {className}">
31
+ <span role={label ? "status" : undefined} class="inline-flex {className}">
30
32
  <svg
31
- class="animate-spin {sizeClasses[size]}"
33
+ class="animate-spin {typeof size === 'number' ? '' : sizeClasses[size]}"
34
+ width={typeof size === "number" ? size : undefined}
35
+ height={typeof size === "number" ? size : undefined}
32
36
  viewBox="0 0 24 24"
33
37
  fill="none"
34
38
  xmlns="http://www.w3.org/2000/svg"
39
+ aria-hidden="true"
35
40
  >
36
41
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke={color} stroke-width="4"></circle>
37
42
  <path
@@ -40,5 +45,5 @@
40
45
  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
41
46
  ></path>
42
47
  </svg>
43
- <span class="sr-only">{label}</span>
44
- </div>
48
+ {#if label}<span class="sr-only">{label}</span>{/if}
49
+ </span>
@@ -2,7 +2,10 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
+ import { Calendar, Ban, Check, Clock, Pause, X } from "@lucide/svelte";
6
+ import type { Component } from "svelte";
5
7
  import { fade } from "svelte/transition";
8
+ import Spinner from "./Spinner.svelte";
6
9
 
7
10
  type Status =
8
11
  "running" | "success" | "failed" | "pending" | "paused" | "scheduled" | "cancelled";
@@ -26,41 +29,65 @@
26
29
  class: className = "",
27
30
  }: Props = $props();
28
31
 
29
- const STATUS_CONFIG: Record<Status, { label: string; classes: string; iconColor: string }> = {
32
+ // `icon` is undefined for "running", which shows the spinner.
33
+ const STATUS_CONFIG: Record<
34
+ Status,
35
+ {
36
+ label: string;
37
+ classes: string;
38
+ iconColor: string;
39
+ icon: Component | undefined;
40
+ strokeWidth: number;
41
+ }
42
+ > = {
30
43
  running: {
31
44
  label: "Running",
32
45
  classes: "bg-info-soft text-info-soft-text border-info-soft-border",
33
46
  iconColor: "text-info-surface",
47
+ icon: undefined,
48
+ strokeWidth: 3,
34
49
  },
35
50
  success: {
36
51
  label: "Success",
37
52
  classes: "bg-success-soft text-success-soft-text border-success-soft-border",
38
53
  iconColor: "text-success-surface",
54
+ icon: Check,
55
+ strokeWidth: 3,
39
56
  },
40
57
  failed: {
41
58
  label: "Failed",
42
59
  classes: "bg-danger-soft text-danger-soft-text border-danger-soft-border",
43
60
  iconColor: "text-danger-surface",
61
+ icon: X,
62
+ strokeWidth: 3,
44
63
  },
45
64
  pending: {
46
65
  label: "Pending",
47
66
  classes: "bg-warning-soft text-warning-soft-text border-warning-soft-border",
48
67
  iconColor: "text-warning-surface",
68
+ icon: Clock,
69
+ strokeWidth: 3,
49
70
  },
50
71
  paused: {
51
72
  label: "Paused",
52
73
  classes: "bg-surface-sunken text-on-surface-muted border-outline",
53
74
  iconColor: "text-on-surface-faint",
75
+ icon: Pause,
76
+ strokeWidth: 0,
54
77
  },
55
78
  scheduled: {
56
79
  label: "Scheduled",
57
80
  classes: "bg-primary-soft text-primary-soft-text border-primary-soft-border",
58
81
  iconColor: "text-primary",
82
+ icon: Calendar,
83
+ strokeWidth: 2.5,
59
84
  },
60
85
  cancelled: {
61
86
  label: "Cancelled",
62
87
  classes: "bg-surface-sunken text-on-surface-faint border-outline opacity-75",
63
88
  iconColor: "text-on-surface-faint",
89
+ icon: Ban,
90
+ strokeWidth: 2.5,
64
91
  },
65
92
  };
66
93
 
@@ -80,109 +107,15 @@
80
107
  in:fade={{ duration: 150 }}
81
108
  >
82
109
  <span class="flex shrink-0 items-center justify-center {config.iconColor}">
83
- {#if status === "running" || pulse}
84
- <svg
85
- width={sizeConfig.iconSize}
86
- height={sizeConfig.iconSize}
87
- viewBox="0 0 24 24"
88
- fill="none"
89
- class="animate-spin"
90
- >
91
- <circle
92
- class="opacity-25"
93
- cx="12"
94
- cy="12"
95
- r="10"
96
- stroke="currentColor"
97
- stroke-width="4"
98
- ></circle>
99
- <path
100
- class="opacity-75"
101
- fill="currentColor"
102
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
103
- ></path>
104
- </svg>
105
- {:else if status === "success"}
106
- <svg
107
- width={sizeConfig.iconSize}
108
- height={sizeConfig.iconSize}
109
- viewBox="0 0 24 24"
110
- fill="none"
111
- stroke="currentColor"
112
- stroke-width="3"
113
- stroke-linecap="round"
114
- stroke-linejoin="round"
115
- >
116
- <polyline points="20 6 9 17 4 12"></polyline>
117
- </svg>
118
- {:else if status === "failed"}
119
- <svg
120
- width={sizeConfig.iconSize}
121
- height={sizeConfig.iconSize}
122
- viewBox="0 0 24 24"
123
- fill="none"
124
- stroke="currentColor"
125
- stroke-width="3"
126
- stroke-linecap="round"
127
- stroke-linejoin="round"
128
- >
129
- <line x1="18" y1="6" x2="6" y2="18"></line>
130
- <line x1="6" y1="6" x2="18" y2="18"></line>
131
- </svg>
132
- {:else if status === "pending"}
133
- <svg
134
- width={sizeConfig.iconSize}
135
- height={sizeConfig.iconSize}
136
- viewBox="0 0 24 24"
137
- fill="none"
138
- stroke="currentColor"
139
- stroke-width="3"
140
- stroke-linecap="round"
141
- stroke-linejoin="round"
142
- >
143
- <circle cx="12" cy="12" r="10"></circle>
144
- <polyline points="12 6 12 12 16 14"></polyline>
145
- </svg>
146
- {:else if status === "scheduled"}
147
- <svg
148
- width={sizeConfig.iconSize}
149
- height={sizeConfig.iconSize}
150
- viewBox="0 0 24 24"
151
- fill="none"
152
- stroke="currentColor"
153
- stroke-width="2.5"
154
- stroke-linecap="round"
155
- stroke-linejoin="round"
156
- >
157
- <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
158
- <line x1="16" y1="2" x2="16" y2="6"></line>
159
- <line x1="8" y1="2" x2="8" y2="6"></line>
160
- <line x1="3" y1="10" x2="21" y2="10"></line>
161
- </svg>
162
- {:else if status === "paused"}
163
- <svg
164
- width={sizeConfig.iconSize}
165
- height={sizeConfig.iconSize}
166
- viewBox="0 0 24 24"
167
- fill="currentColor"
168
- >
169
- <rect x="6" y="4" width="4" height="16" rx="1" />
170
- <rect x="14" y="4" width="4" height="16" rx="1" />
171
- </svg>
172
- {:else if status === "cancelled"}
173
- <svg
174
- width={sizeConfig.iconSize}
175
- height={sizeConfig.iconSize}
176
- viewBox="0 0 24 24"
177
- fill="none"
178
- stroke="currentColor"
179
- stroke-width="2.5"
180
- stroke-linecap="round"
181
- stroke-linejoin="round"
182
- >
183
- <circle cx="12" cy="12" r="10"></circle>
184
- <line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
185
- </svg>
110
+ {#if !config.icon || pulse}
111
+ <Spinner size={sizeConfig.iconSize} label="" />
112
+ {:else}
113
+ {@const Icon = config.icon}
114
+ <Icon
115
+ size={sizeConfig.iconSize}
116
+ strokeWidth={config.strokeWidth}
117
+ fill={status === "paused" ? "currentColor" : "none"}
118
+ />
186
119
  {/if}
187
120
  </span>
188
121
 
@@ -20,6 +20,8 @@
20
20
  onChange?: (tabId: string) => void;
21
21
  variant?: "underline" | "pills" | "enclosed";
22
22
  fullWidth?: boolean;
23
+ /** Scroll the tab bar sideways instead of wrapping when the labels outgrow it. */
24
+ scrollable?: boolean;
23
25
  children?: Snippet<[string]>;
24
26
  class?: string;
25
27
  }
@@ -30,6 +32,7 @@
30
32
  onChange,
31
33
  variant = "underline",
32
34
  fullWidth = false,
35
+ scrollable = false,
33
36
  children,
34
37
  class: className = "",
35
38
  }: Props = $props();
@@ -79,12 +82,13 @@
79
82
  }
80
83
  </script>
81
84
 
82
- <div class={className}>
85
+ {#snippet tablist()}
83
86
  <div
84
87
  class="
85
88
  {variant === 'underline' ? 'border-b border-outline' : ''}
86
89
  {variant === 'pills' ? 'rounded-[4px] bg-surface-sunken p-1' : ''}
87
90
  {variant === 'enclosed' ? 'rounded-[4px] border border-outline bg-surface-sunken p-1' : ''}
91
+ {scrollable ? 'w-max min-w-full whitespace-nowrap' : ''}
88
92
  "
89
93
  role="tablist"
90
94
  >
@@ -131,6 +135,16 @@
131
135
  {/each}
132
136
  </nav>
133
137
  </div>
138
+ {/snippet}
139
+
140
+ <div class={className}>
141
+ {#if scrollable}
142
+ <div class="overflow-x-auto">
143
+ {@render tablist()}
144
+ </div>
145
+ {:else}
146
+ {@render tablist()}
147
+ {/if}
134
148
 
135
149
  {#if children && renderedActiveTab}
136
150
  <div
@@ -0,0 +1,156 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { ChevronDown, Download, Play, RotateCw, Square, Trash } from "@lucide/svelte";
6
+ import Button from "../Button.svelte";
7
+ import Popover from "../Popover.svelte";
8
+ import { runLogDownloadUrl } from "./run-helpers.js";
9
+
10
+ let {
11
+ runId,
12
+ taskName,
13
+ isRunning,
14
+ canDelete,
15
+ onRun,
16
+ onRunAgain,
17
+ onStop,
18
+ onDelete,
19
+ stopLabel = "Stop",
20
+ }: {
21
+ runId: string;
22
+ taskName: string;
23
+ isRunning: boolean;
24
+ canDelete: boolean;
25
+ onRun: (() => void) | undefined;
26
+ onRunAgain: (() => void) | undefined;
27
+ onStop: ((runId: string) => void) | undefined;
28
+ onDelete: ((runId: string) => void) | undefined;
29
+ stopLabel?: string | undefined;
30
+ } = $props();
31
+
32
+ // Inline-confirm popover for delete.
33
+ let confirmDeleteOpen = $state(false);
34
+ // Dropdown half of the Run split button (the "reuse parameters" variant).
35
+ let runMenuOpen = $state(false);
36
+ </script>
37
+
38
+ <!-- Run-level actions: run (split) · stop · download · delete (delete behind an
39
+ inline confirm). Task-level ones (pause, service start/stop/restart) live
40
+ on the task header. -->
41
+ <div class="flex flex-wrap items-center gap-2">
42
+ {#if onRun}
43
+ <!-- Split button: Run (defaults) + an arrow for the reuse-parameters
44
+ variant, shown only when there is one. -->
45
+ <div class="inline-flex overflow-hidden rounded-[3px] border border-primary-soft-border">
46
+ <button
47
+ type="button"
48
+ onclick={() => onRun()}
49
+ title="Run this task now with default options"
50
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 bg-surface-raised px-3 font-mono text-sm font-medium text-primary hover:bg-primary-soft active:translate-y-px"
51
+ >
52
+ <Play size={15} fill="currentColor" stroke="none" />
53
+ <span class="@max-xs:hidden">Run</span>
54
+ </button>
55
+ {#if onRunAgain}
56
+ <Popover bind:open={runMenuOpen} placement="bottom-end">
57
+ {#snippet trigger()}
58
+ <span
59
+ class="flex h-9 cursor-pointer items-center border-l border-primary-soft-border bg-surface-raised px-2 text-primary hover:bg-primary-soft"
60
+ title="More run options"
61
+ aria-label="More run options"
62
+ >
63
+ <ChevronDown size={15} />
64
+ </span>
65
+ {/snippet}
66
+ <button
67
+ type="button"
68
+ onclick={() => {
69
+ runMenuOpen = false;
70
+ onRunAgain();
71
+ }}
72
+ class="flex w-56 items-start gap-2.5 text-left"
73
+ >
74
+ <RotateCw size={15} class="mt-0.5 shrink-0 text-on-surface-muted" />
75
+ <span class="flex flex-col">
76
+ <span class="font-mono text-sm font-medium text-on-surface"
77
+ >Run again</span
78
+ >
79
+ <span class="text-xs text-on-surface-muted"
80
+ >Reuse this run's parameters</span
81
+ >
82
+ </span>
83
+ </button>
84
+ </Popover>
85
+ {/if}
86
+ </div>
87
+ {/if}
88
+ {#if onRunAgain && !onRun && !isRunning}
89
+ <!-- No Run button here (the page has its own), so re-running with this
90
+ run's parameters stands alone. -->
91
+ <button
92
+ type="button"
93
+ onclick={() => onRunAgain()}
94
+ title="Run again, reusing this run's parameters"
95
+ aria-label="Run again"
96
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-primary-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-primary hover:bg-primary-soft active:translate-y-px @max-md:px-2.5"
97
+ >
98
+ <RotateCw size={15} />
99
+ <span class="@max-md:hidden">Run again</span>
100
+ </button>
101
+ {/if}
102
+ {#if isRunning && onStop}
103
+ <button
104
+ type="button"
105
+ onclick={() => onStop(runId)}
106
+ aria-label={stopLabel}
107
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-danger-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-danger-surface hover:bg-danger-soft active:translate-y-px"
108
+ >
109
+ <Square size={15} fill="currentColor" stroke="none" />
110
+ <span class="@max-xs:hidden">{stopLabel}</span>
111
+ </button>
112
+ {/if}
113
+ <a
114
+ href={runLogDownloadUrl(runId)}
115
+ download="{taskName}-{runId}.log"
116
+ class="inline-flex items-center justify-center rounded-[3px] border border-outline-faint bg-surface-raised p-2 text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary"
117
+ title="Download the full log (rotated and current parts as one file)"
118
+ aria-label="Download log"
119
+ >
120
+ <Download size={15} />
121
+ </a>
122
+ {#if canDelete && onDelete}
123
+ <Popover bind:open={confirmDeleteOpen} placement="bottom-end">
124
+ {#snippet trigger()}
125
+ <span
126
+ class="flex cursor-pointer items-center justify-center rounded-[3px] border border-outline-faint bg-surface-raised p-2 text-on-surface-muted hover:border-danger-soft-border hover:bg-danger-soft hover:text-danger-surface"
127
+ title="Delete this run"
128
+ aria-label="Delete run"
129
+ >
130
+ <Trash size={15} />
131
+ </span>
132
+ {/snippet}
133
+ <div class="w-60">
134
+ <div class="font-mono text-sm font-semibold text-on-surface">Delete this run?</div>
135
+ <div class="mt-1 text-xs leading-relaxed text-on-surface-muted">
136
+ Its captured output is removed from disk. This can't be undone.
137
+ </div>
138
+ <div class="mt-3 flex justify-end gap-2">
139
+ <Button variant="ghost" size="sm" onclick={() => (confirmDeleteOpen = false)}>
140
+ Cancel
141
+ </Button>
142
+ <Button
143
+ variant="danger"
144
+ size="sm"
145
+ onclick={() => {
146
+ confirmDeleteOpen = false;
147
+ onDelete(runId);
148
+ }}
149
+ >
150
+ Delete run
151
+ </Button>
152
+ </div>
153
+ </div>
154
+ </Popover>
155
+ {/if}
156
+ </div>