@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.
Files changed (87) hide show
  1. package/package.json +4 -5
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/focusTrap.ts +3 -3
  4. package/src/lib/actions/portal.ts +5 -1
  5. package/src/lib/components/Accordion.svelte +6 -2
  6. package/src/lib/components/AccordionItem.svelte +80 -23
  7. package/src/lib/components/Avatar.svelte +0 -1
  8. package/src/lib/components/Badge.svelte +1 -1
  9. package/src/lib/components/CodeBlock.svelte +84 -35
  10. package/src/lib/components/CronInput.svelte +5 -2
  11. package/src/lib/components/DataGrid.svelte +100 -138
  12. package/src/lib/components/Drawer.svelte +2 -6
  13. package/src/lib/components/Dropdown.svelte +19 -57
  14. package/src/lib/components/EmptyState.svelte +2 -1
  15. package/src/lib/components/ErrorState.svelte +2 -1
  16. package/src/lib/components/FilterBar.svelte +1 -1
  17. package/src/lib/components/FilterPill.svelte +2 -2
  18. package/src/lib/components/FormField.svelte +1 -1
  19. package/src/lib/components/Heading.svelte +19 -12
  20. package/src/lib/components/Kbd.svelte +25 -10
  21. package/src/lib/components/Link.svelte +7 -1
  22. package/src/lib/components/LogConsole.svelte +40 -42
  23. package/src/lib/components/Modal.svelte +44 -33
  24. package/src/lib/components/NotificationBell.svelte +2 -1
  25. package/src/lib/components/PageContainer.svelte +1 -10
  26. package/src/lib/components/PageHeader.svelte +8 -4
  27. package/src/lib/components/Popover.svelte +9 -51
  28. package/src/lib/components/Progress.svelte +19 -6
  29. package/src/lib/components/Radio.svelte +16 -4
  30. package/src/lib/components/RadioGroup.svelte +15 -2
  31. package/src/lib/components/SearchInput.svelte +1 -1
  32. package/src/lib/components/Select.svelte +16 -47
  33. package/src/lib/components/Sparkline.svelte +2 -2
  34. package/src/lib/components/StatusDot.svelte +1 -1
  35. package/src/lib/components/Table.svelte +13 -4
  36. package/src/lib/components/TableBody.svelte +2 -1
  37. package/src/lib/components/TableCell.svelte +38 -8
  38. package/src/lib/components/TableHead.svelte +12 -4
  39. package/src/lib/components/TableRow.svelte +23 -3
  40. package/src/lib/components/TaskCard.svelte +1 -1
  41. package/src/lib/components/TimezoneSelect.svelte +2 -2
  42. package/src/lib/components/Tooltip.svelte +1 -1
  43. package/src/lib/components/accordion.svelte.ts +19 -0
  44. package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
  45. package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
  46. package/src/lib/components/dashboard/RunsList.svelte +17 -26
  47. package/src/lib/components/dashboard/cron-format.test.ts +1 -1
  48. package/src/lib/components/dashboard/cron-format.ts +3 -3
  49. package/src/lib/components/dashboard/run-filters.test.ts +4 -60
  50. package/src/lib/components/dashboard/run-filters.ts +29 -144
  51. package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
  52. package/src/lib/components/dashboard/run-helpers.ts +40 -39
  53. package/src/lib/components/dashboard/status-config.ts +38 -83
  54. package/src/lib/components/dashboard/types.ts +0 -26
  55. package/src/lib/components/data-grid-selection.ts +2 -9
  56. package/src/lib/components/filter-spec.ts +3 -3
  57. package/src/lib/components/radio-group.ts +11 -0
  58. package/src/lib/index.ts +8 -43
  59. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  60. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  61. package/src/lib/log-console/ansi.ts +1 -1
  62. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  63. package/src/lib/log-console/highlight-scroll.ts +41 -0
  64. package/src/lib/theme-tokens.css +21 -21
  65. package/src/lib/theme.css +22 -16
  66. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  67. package/src/lib/utils/clipboard.svelte.ts +73 -0
  68. package/src/lib/utils/format.test.ts +3 -3
  69. package/src/lib/utils/format.ts +65 -94
  70. package/src/lib/utils/id.test.ts +3 -7
  71. package/src/lib/utils/id.ts +3 -2
  72. package/src/lib/utils/theme.svelte.test.ts +0 -6
  73. package/src/lib/utils/theme.svelte.ts +3 -5
  74. package/src/lib/utils/toast.svelte.ts +5 -14
  75. package/src/stories/Accordion.stories.svelte +61 -0
  76. package/src/stories/CodeBlock.stories.svelte +55 -0
  77. package/src/stories/DataGrid.stories.svelte +0 -5
  78. package/src/stories/Heading.stories.svelte +31 -0
  79. package/src/stories/Kbd.stories.svelte +36 -0
  80. package/src/stories/Link.stories.svelte +38 -0
  81. package/src/stories/LogConsole.stories.svelte +2 -2
  82. package/src/stories/PageHeader.stories.svelte +31 -0
  83. package/src/stories/Progress.stories.svelte +26 -0
  84. package/src/stories/RadioGroup.stories.svelte +43 -0
  85. package/src/stories/Table.stories.svelte +69 -0
  86. package/src/svelte-shims.d.ts +0 -5
  87. package/src/lib/components/dashboard/status-config.test.ts +0 -18
@@ -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",
@@ -41,7 +41,7 @@
41
41
  // External writes to `value` (Reset / clear-all) win immediately: adopt them
42
42
  // and drop any pending debounce. Depends only on `value` (raw is read
43
43
  // untracked), so typing never re-enters here. Tiny race: a reset that leaves
44
- // `value` unchanged ("" -> "") can't cancel a first-keystroke debounce —
44
+ // `value` unchanged ("" -> "") can't cancel a first-keystroke debounce,
45
45
  // negligible at 250ms. ponytail: guarded two-way sync, fine for one field.
46
46
  $effect(() => {
47
47
  const v = value;
@@ -16,10 +16,11 @@
16
16
  </script>
17
17
 
18
18
  <script lang="ts">
19
- import { tick, onDestroy } from "svelte";
19
+ import { tick } from "svelte";
20
20
  import { ChevronDown, Check, Search } from "@lucide/svelte";
21
- import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
22
- import { generateUlid } from "@runwisp/common";
21
+ import { floating, isOutsideClick } from "../actions/floating.js";
22
+ import { portal } from "../actions/portal.js";
23
+ import { trapFocus } from "../actions/focusTrap.js";
23
24
 
24
25
  type SelectSize = "sm" | "md" | "lg";
25
26
 
@@ -32,13 +33,15 @@
32
33
  searchable?: boolean;
33
34
  size?: SelectSize;
34
35
  error?: string;
35
- hint?: string;
36
+ hint?: string | undefined;
36
37
  class?: string;
37
38
  id?: string;
38
39
  name?: string;
39
40
  onchange?: (value: unknown) => void;
40
41
  }
41
42
 
43
+ const uid = $props.id();
44
+
42
45
  let {
43
46
  value = $bindable(),
44
47
  options = [],
@@ -50,7 +53,7 @@
50
53
  error,
51
54
  hint,
52
55
  class: className = "",
53
- id = `select-${generateUlid()}`,
56
+ id = `select-${uid}`,
54
57
  name,
55
58
  onchange,
56
59
  onsearch,
@@ -62,7 +65,6 @@
62
65
  let menuEl = $state<HTMLElement | null>(null);
63
66
  let searchInputEl = $state<HTMLInputElement | null>(null);
64
67
  let focusedIndex = $state(-1);
65
- let cleanupFloating = $state<(() => void) | null>(null);
66
68
 
67
69
  $effect(() => {
68
70
  if (searchable && onsearch) {
@@ -107,31 +109,6 @@
107
109
  return list;
108
110
  });
109
111
 
110
- import { portal } from "../actions/portal.js";
111
- import { trapFocus } from "../actions/focusTrap.js";
112
-
113
- async function setupFloating() {
114
- if (!triggerEl || !menuEl) return;
115
-
116
- menuEl.style.width = `${triggerEl.offsetWidth}px`;
117
-
118
- cleanupFloating = autoUpdate(triggerEl, menuEl, () => {
119
- if (!triggerEl || !menuEl) return;
120
- void computePosition(triggerEl, menuEl, {
121
- placement: "bottom-start",
122
- middleware: [offset(6), flip(), shift({ padding: 10 })],
123
- }).then(({ x, y }) => {
124
- if (menuEl) {
125
- Object.assign(menuEl.style, {
126
- left: `${x}px`,
127
- top: `${y}px`,
128
- position: "absolute",
129
- });
130
- }
131
- });
132
- });
133
- }
134
-
135
112
  function toggle() {
136
113
  if (disabled) return;
137
114
  isOpen = !isOpen;
@@ -141,14 +118,10 @@
141
118
  if (focusedIndex === -1) focusedIndex = 0;
142
119
 
143
120
  void tick().then(() => {
144
- void setupFloating();
145
121
  if (searchable && searchInputEl) {
146
122
  searchInputEl.focus();
147
123
  }
148
124
  });
149
- } else {
150
- cleanupFloating?.();
151
- cleanupFloating = null;
152
125
  }
153
126
  }
154
127
 
@@ -157,7 +130,6 @@
157
130
  value = option.value;
158
131
  onchange?.(value);
159
132
  isOpen = false;
160
- cleanupFloating?.();
161
133
  }
162
134
 
163
135
  function handleKeydown(e: KeyboardEvent) {
@@ -172,7 +144,6 @@
172
144
  switch (e.key) {
173
145
  case "Escape":
174
146
  isOpen = false;
175
- cleanupFloating?.();
176
147
  triggerEl?.focus();
177
148
  break;
178
149
  case "ArrowDown":
@@ -212,18 +183,9 @@
212
183
  }
213
184
 
214
185
  function handleOutsideClick(e: MouseEvent) {
215
- if (!isOpen) return;
216
- const path = e.composedPath();
217
- if (triggerEl && path.includes(triggerEl)) return;
218
- if (menuEl && path.includes(menuEl)) return;
219
- isOpen = false;
220
- cleanupFloating?.();
186
+ if (isOpen && isOutsideClick(e, triggerEl, menuEl)) isOpen = false;
221
187
  }
222
188
 
223
- onDestroy(() => {
224
- cleanupFloating?.();
225
- });
226
-
227
189
  const sizeClasses = {
228
190
  sm: "px-2.5 py-1.5 text-xs",
229
191
  md: "px-3.5 py-2 text-sm",
@@ -303,6 +265,13 @@
303
265
  <div
304
266
  use:portal
305
267
  use:trapFocus={{ autoFocus: false }}
268
+ use:floating={{
269
+ reference: triggerEl,
270
+ placement: "bottom-start",
271
+ offset: 6,
272
+ padding: 10,
273
+ matchWidth: true,
274
+ }}
306
275
  bind:this={menuEl}
307
276
  class="z-[9999] min-w-[200px]"
308
277
  >
@@ -12,7 +12,7 @@
12
12
  }
13
13
 
14
14
  // `currentColor` by default so the caller can theme us with a Tailwind
15
- // text-* class — that's the only way to flip on .dark without burning a
15
+ // text-* class, that's the only way to flip on .dark without burning a
16
16
  // hardcoded hex in here.
17
17
  let {
18
18
  data,
@@ -47,7 +47,7 @@
47
47
  return `${pathD} L${lastX.toFixed(1)},${height} L0,${height} Z`;
48
48
  });
49
49
 
50
- // Use `fill-opacity` rather than appending "18" to a hex — that trick
50
+ // Use `fill-opacity` rather than appending "18" to a hex, that trick
51
51
  // breaks for `currentColor` / `oklch()` / `var(...)` values.
52
52
  let resolvedFill = $derived(fillColor ?? color);
53
53
  let resolvedFillOpacity = $derived(fillColor === undefined ? fillOpacity : 1);
@@ -8,7 +8,7 @@
8
8
  <script lang="ts">
9
9
  interface Props {
10
10
  tone?: StatusDotTone;
11
- /** Radar ping around the dot — use for "live" states only. */
11
+ /** Radar ping around the dot, use for "live" states only. */
12
12
  pulse?: boolean;
13
13
  size?: "sm" | "md" | "lg";
14
14
  class?: string;
@@ -6,14 +6,23 @@
6
6
 
7
7
  interface Props {
8
8
  children: Snippet;
9
+ /** Render only the <table>, without the scrolling bordered frame, for a
10
+ * parent that supplies its own (as DataGrid does). */
11
+ bare?: boolean;
9
12
  class?: string;
10
13
  }
11
14
 
12
- let { children, class: className = "" }: Props = $props();
15
+ let { children, bare = false, class: className = "" }: Props = $props();
13
16
  </script>
14
17
 
15
- <div class="overflow-x-auto rounded-[4px] border border-outline {className}">
16
- <table class="w-full text-sm">
18
+ {#if bare}
19
+ <table class="w-full text-sm {className}">
17
20
  {@render children()}
18
21
  </table>
19
- </div>
22
+ {:else}
23
+ <div class="overflow-x-auto rounded-[4px] border border-outline {className}">
24
+ <table class="w-full text-sm">
25
+ {@render children()}
26
+ </table>
27
+ </div>
28
+ {/if}
@@ -12,6 +12,7 @@
12
12
  let { children, class: className = "" }: Props = $props();
13
13
  </script>
14
14
 
15
- <tbody class={className}>
15
+ <!-- The last row drops its divider: the frame below already draws that line. -->
16
+ <tbody class="[&>tr:last-child]:border-b-0 {className}">
16
17
  {@render children()}
17
18
  </tbody>
@@ -3,17 +3,30 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import type { HTMLTdAttributes, HTMLThAttributes } from "svelte/elements";
6
7
 
7
8
  type CellAlign = "left" | "center" | "right";
9
+ type CellDensity = "compact" | "default" | "comfortable" | "none";
8
10
 
9
- interface Props {
11
+ interface Props extends Omit<
12
+ HTMLTdAttributes & HTMLThAttributes,
13
+ "align" | "children" | "class"
14
+ > {
10
15
  header?: boolean;
11
- align?: CellAlign;
12
- children: Snippet;
16
+ align?: CellAlign | undefined;
17
+ density?: CellDensity;
18
+ children?: Snippet;
13
19
  class?: string;
14
20
  }
15
21
 
16
- let { header = false, align = "left", children, class: className = "" }: Props = $props();
22
+ let {
23
+ header = false,
24
+ align = "left",
25
+ density = "default",
26
+ children,
27
+ class: className = "",
28
+ ...restProps
29
+ }: Props = $props();
17
30
 
18
31
  const alignClasses: Record<CellAlign, string> = {
19
32
  left: "text-left",
@@ -21,14 +34,31 @@
21
34
  right: "text-right",
22
35
  };
23
36
 
37
+ const densityClasses: Record<CellDensity, string> = {
38
+ compact: "px-3 py-2",
39
+ default: "px-4 py-3",
40
+ comfortable: "px-5 py-3.5",
41
+ none: "",
42
+ };
43
+
44
+ // A header with aria-sort set lights up with a primary underline, so a
45
+ // sorted column is marked for sighted users and screen readers alike.
46
+ const headerClasses = `
47
+ font-mono text-xs tracking-[0.08em] text-on-surface-faint uppercase
48
+ aria-[sort=ascending]:text-on-surface aria-[sort=descending]:text-on-surface
49
+ aria-[sort=ascending]:shadow-[inset_0_-2px_0_var(--color-primary)]
50
+ aria-[sort=descending]:shadow-[inset_0_-2px_0_var(--color-primary)]
51
+ `;
52
+
24
53
  const tag = $derived(header ? "th" : "td");
25
54
  </script>
26
55
 
27
56
  <svelte:element
28
57
  this={tag}
29
- class="px-4 py-3 {alignClasses[align]} {header
30
- ? 'font-mono text-xs tracking-wide text-on-surface-faint uppercase'
31
- : 'text-on-surface'} {className}"
58
+ class="{densityClasses[density]} {alignClasses[align]} {header
59
+ ? headerClasses
60
+ : ''} {className}"
61
+ {...restProps}
32
62
  >
33
- {@render children()}
63
+ {@render children?.()}
34
64
  </svelte:element>
@@ -3,17 +3,25 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import TableRow from "./TableRow.svelte";
6
7
 
7
8
  interface Props {
8
9
  children: Snippet;
10
+ /** Wrap children in one header row. Turn off to pass your own
11
+ * TableRow(s), e.g. for a two-row header. */
12
+ row?: boolean;
9
13
  class?: string;
10
14
  }
11
15
 
12
- let { children, class: className = "" }: Props = $props();
16
+ let { children, row = true, class: className = "" }: Props = $props();
13
17
  </script>
14
18
 
15
- <thead class="bg-surface-sunken/50 {className}">
16
- <tr>
19
+ <thead class={className}>
20
+ {#if row}
21
+ <TableRow>
22
+ {@render children()}
23
+ </TableRow>
24
+ {:else}
17
25
  {@render children()}
18
- </tr>
26
+ {/if}
19
27
  </thead>
@@ -3,16 +3,36 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import type { HTMLAttributes } from "svelte/elements";
6
7
 
7
- interface Props {
8
+ type RowBorder = "default" | "faint" | "none";
9
+
10
+ interface Props extends Omit<HTMLAttributes<HTMLTableRowElement>, "children" | "class"> {
8
11
  children: Snippet;
9
12
  hoverable?: boolean;
13
+ /** Bottom divider: `default` under a header, `faint` between body rows. */
14
+ border?: RowBorder;
10
15
  class?: string;
11
16
  }
12
17
 
13
- let { children, hoverable = false, class: className = "" }: Props = $props();
18
+ let {
19
+ children,
20
+ hoverable = false,
21
+ border = "default",
22
+ class: className = "",
23
+ ...restProps
24
+ }: Props = $props();
25
+
26
+ const borderClasses: Record<RowBorder, string> = {
27
+ default: "border-b border-outline",
28
+ faint: "border-b border-outline-faint",
29
+ none: "",
30
+ };
14
31
  </script>
15
32
 
16
- <tr class="border-b border-outline {hoverable ? 'hover:bg-surface-sunken' : ''} {className}">
33
+ <tr
34
+ class="{borderClasses[border]} {hoverable ? 'hover:bg-surface-sunken' : ''} {className}"
35
+ {...restProps}
36
+ >
17
37
  {@render children()}
18
38
  </tr>
@@ -4,7 +4,7 @@
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
6
 
7
- export type TaskCardAccent = "danger" | "wisp" | "aurora";
7
+ type TaskCardAccent = "danger" | "wisp" | "aurora";
8
8
 
9
9
  interface Props {
10
10
  accent?: TaskCardAccent;
@@ -11,12 +11,12 @@
11
11
  label = "Timezone",
12
12
  disabled = false,
13
13
  hint,
14
- } = $props<{
14
+ }: {
15
15
  value: string;
16
16
  label?: string;
17
17
  disabled?: boolean;
18
18
  hint?: string;
19
- }>();
19
+ } = $props();
20
20
 
21
21
  type ZoneOption = {
22
22
  value: string;
@@ -45,7 +45,7 @@
45
45
  {@render children()}
46
46
 
47
47
  <!-- Hover intent: 100ms before it appears, so sweeping the pointer across a
48
- dense row doesn't flash a trail of bubbles. Still no fade — `duration-0`
48
+ dense row doesn't flash a trail of bubbles. Still no fade, `duration-0`
49
49
  keeps the appearance instant once the delay is served, per DESIGN.md. -->
50
50
  <div
51
51
  class="
@@ -0,0 +1,19 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export const ACCORDION_KEY = Symbol("accordion");
5
+
6
+ // Shared by an Accordion and its items. In single mode an item records itself
7
+ // as `active` when it opens, and every other item closes in response.
8
+ export class AccordionGroup {
9
+ active = $state<string | null>(null);
10
+ readonly #isSingle: () => boolean;
11
+
12
+ constructor(isSingle: () => boolean) {
13
+ this.#isSingle = isSingle;
14
+ }
15
+
16
+ get single(): boolean {
17
+ return this.#isSingle();
18
+ }
19
+ }