@runwisp/ui 1.0.0 → 1.1.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 (31) hide show
  1. package/package.json +4 -4
  2. package/src/astro/SiteFooter.astro +149 -31
  3. package/src/lib/components/AlertDialog.svelte +63 -25
  4. package/src/lib/components/Badge.svelte +51 -33
  5. package/src/lib/components/Button.svelte +1 -1
  6. package/src/lib/components/DataGrid.svelte +151 -90
  7. package/src/lib/components/FilterBar.svelte +156 -0
  8. package/src/lib/components/FilterPill.svelte +180 -0
  9. package/src/lib/components/NotificationBell.svelte +70 -0
  10. package/src/lib/components/SearchInput.svelte +91 -0
  11. package/src/lib/components/StatusDot.svelte +38 -0
  12. package/src/lib/components/ThemeToggle.svelte +28 -33
  13. package/src/lib/components/Tooltip.svelte +5 -2
  14. package/src/lib/components/dashboard/run-filters.test.ts +2 -2
  15. package/src/lib/components/dashboard/run-filters.ts +5 -5
  16. package/src/lib/components/dashboard/run-helpers.test.ts +1 -1
  17. package/src/lib/components/dashboard/run-helpers.ts +1 -1
  18. package/src/lib/components/filter-spec.ts +94 -0
  19. package/src/lib/index.ts +26 -1
  20. package/src/lib/utils/debounce.test.ts +31 -0
  21. package/src/lib/utils/debounce.ts +22 -0
  22. package/src/lib/utils/filter.test.ts +172 -0
  23. package/src/lib/utils/filter.ts +79 -0
  24. package/src/lib/utils/theme.svelte.test.ts +142 -0
  25. package/src/lib/utils/theme.svelte.ts +71 -0
  26. package/src/stories/DataGrid.stories.svelte +64 -1
  27. package/src/stories/FilterBar.stories.svelte +61 -0
  28. package/src/stories/Welcome.mdx +1 -1
  29. package/src/astro/CTABand.astro +0 -39
  30. package/src/astro/Hero.astro +0 -58
  31. package/src/astro/SiteHeader.astro +0 -46
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@chromatic-com/storybook": "^5.2.1",
51
51
  "@eslint/js": "^10.0.1",
52
- "@runwisp/eslint-config": "1.0.0",
53
- "@runwisp/typescript-config": "1.0.0",
52
+ "@runwisp/eslint-config": "1.1.0",
53
+ "@runwisp/typescript-config": "1.1.0",
54
54
  "@storybook/addon-a11y": "^10.5.5",
55
55
  "@storybook/addon-docs": "^10.5.5",
56
56
  "@storybook/addon-svelte-csf": "^5.1.2",
@@ -93,7 +93,7 @@
93
93
  "@fontsource-variable/geist-mono": "^5.3.0",
94
94
  "@fontsource/tasa-orbiter": "^5.3.0",
95
95
  "@lucide/svelte": "^1.28.0",
96
- "@runwisp/common": "1.0.0",
96
+ "@runwisp/common": "1.1.0",
97
97
  "@tanstack/svelte-virtual": "^3.13.35",
98
98
  "ansi-to-html": "^0.7.2",
99
99
  "cron-parser": "^5.7.0",
@@ -2,46 +2,164 @@
2
2
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ // Brand footer shared by runwisp.com and docs.runwisp.com. Plain scoped CSS on
6
+ // --rw-* tokens (no Tailwind) so it renders the same under Starlight, which
7
+ // only loads theme-tokens.css.
8
+ import logoSvg from "../lib/assets/runwisp-logo.svg?raw";
9
+
10
+ export interface FooterLink {
11
+ label: string;
12
+ href: string;
13
+ external?: boolean;
14
+ }
15
+
16
+ export interface FooterColumn {
17
+ title: string;
18
+ links: FooterLink[];
19
+ }
20
+
5
21
  interface Props {
6
- tagline?: string;
22
+ columns: FooterColumn[];
23
+ homeHref?: string;
24
+ wordmark?: string;
7
25
  class?: string;
8
26
  }
9
27
 
10
- const { tagline = "Built by operators, for operators.", class: className = "" } =
11
- Astro.props;
12
-
13
- const hasTeaser = Astro.slots.has("teaser");
14
- const hasColumns = Astro.slots.has("columns");
15
- const hasBadges = Astro.slots.has("badges");
28
+ const { columns, homeHref = "/", wordmark = "RunWisp", class: className = "" } = Astro.props;
16
29
  ---
17
30
 
18
- <footer class:list={["bg-surface-sunken border-t border-outline", className]}>
19
- <div class="mx-auto max-w-6xl px-6 py-12">
20
- {
21
- hasTeaser && (
22
- <div class="bg-surface-raised mb-10 rounded-[4px] border border-outline p-6 shadow-sm">
23
- <slot name="teaser" />
24
- </div>
25
- )
26
- }
27
- {
28
- hasColumns && (
29
- <div class="grid gap-8 font-mono sm:grid-cols-2 lg:grid-cols-4 [&_a]:block [&_a]:text-on-surface-muted [&_a]:[&_a:hover]:text-primary [&_h4]:mb-3 [&_h4]:text-xs [&_h4]:font-medium [&_h4]:uppercase [&_h4]:tracking-[0.16em] [&_h4]:text-on-surface-faint [&_h4]:before:mr-1.5 [&_h4]:before:content-['──']">
30
- <slot name="columns" />
31
- </div>
32
- )
33
- }
34
- <div
35
- class="text-on-surface-muted mt-10 flex flex-col items-start justify-between gap-4 border-t border-outline-faint pt-6 font-mono text-xs sm:flex-row sm:items-center"
36
- >
37
- <p>{tagline}</p>
31
+ <footer class:list={["rw-ft", className]}>
32
+ <div class="rw-ft-wrap">
33
+ <div class="rw-ft-grid" style={`--rw-ft-cols: ${columns.length}`}>
34
+ <div class="rw-ft-brand">
35
+ <a class="rw-ft-mark" href={homeHref}
36
+ ><span class="rw-ft-logo" aria-hidden="true" set:html={logoSvg} />
37
+ {wordmark}</a
38
+ >
39
+ <slot />
40
+ </div>
38
41
  {
39
- hasBadges && (
40
- <div class="flex flex-wrap items-center gap-3">
41
- <slot name="badges" />
42
+ columns.map((column) => (
43
+ <div class="rw-ft-col">
44
+ <h4>{column.title}</h4>
45
+ {column.links.map((link) => (
46
+ <a
47
+ href={link.href}
48
+ target={link.external ? "_blank" : undefined}
49
+ rel={link.external ? "noopener noreferrer" : undefined}
50
+ >
51
+ {link.label}
52
+ </a>
53
+ ))}
42
54
  </div>
43
- )
55
+ ))
44
56
  }
45
57
  </div>
58
+ {
59
+ Astro.slots.has("bottom") && (
60
+ <div class="rw-ft-bottom">
61
+ <slot name="bottom" />
62
+ </div>
63
+ )
64
+ }
46
65
  </div>
47
66
  </footer>
67
+
68
+ <style>
69
+ .rw-ft {
70
+ border-top: 1px solid var(--rw-outline);
71
+ margin-top: 30px;
72
+ padding: 52px 0 0;
73
+ color: var(--rw-on-surface);
74
+ font-family: var(--rw-font-sans);
75
+ /* Container, not viewport: under Starlight the footer sits in the
76
+ * content column, not the full page width. */
77
+ container-type: inline-size;
78
+ }
79
+ /* Slotted content (blurb, bottom bar) doesn't carry this component's scope,
80
+ * so these reach it via :global, wrapped in :where() to stay at element
81
+ * specificity: any consumer class (e.g. a teaser paragraph) wins. */
82
+ :global(:where(.rw-ft) a) {
83
+ color: inherit;
84
+ text-decoration: none;
85
+ }
86
+ .rw-ft-wrap {
87
+ max-width: 1120px;
88
+ margin: 0 auto;
89
+ padding: 0 clamp(20px, 5vw, 40px);
90
+ }
91
+ .rw-ft-grid {
92
+ display: grid;
93
+ grid-template-columns: 1.6fr repeat(var(--rw-ft-cols), 1fr);
94
+ gap: 30px;
95
+ }
96
+ @container (max-width: 780px) {
97
+ .rw-ft-grid {
98
+ grid-template-columns: 1fr 1fr;
99
+ }
100
+ }
101
+ .rw-ft-mark {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 9px;
105
+ margin-bottom: 14px;
106
+ font-weight: 700;
107
+ font-size: 18px;
108
+ letter-spacing: -0.02em;
109
+ }
110
+ .rw-ft-logo {
111
+ display: inline-flex;
112
+ width: 21px;
113
+ height: 21px;
114
+ color: var(--rw-primary);
115
+ }
116
+ .rw-ft-logo :global(svg) {
117
+ width: 100%;
118
+ height: 100%;
119
+ }
120
+ :global(:where(.rw-ft-brand) p) {
121
+ margin: 0;
122
+ color: var(--rw-on-surface-muted);
123
+ font-size: 13.5px;
124
+ max-width: 30ch;
125
+ }
126
+ .rw-ft-col h4 {
127
+ margin: 0 0 14px;
128
+ font-family: var(--rw-font-mono);
129
+ font-size: 11px;
130
+ font-weight: 500;
131
+ letter-spacing: 0.1em;
132
+ text-transform: lowercase;
133
+ color: var(--rw-on-surface-faint);
134
+ }
135
+ .rw-ft-col h4::before {
136
+ content: "── ";
137
+ }
138
+ .rw-ft-col a {
139
+ display: block;
140
+ padding: 4px 0;
141
+ font-family: var(--rw-font-mono);
142
+ font-size: 13.5px;
143
+ color: var(--rw-on-surface-muted);
144
+ transition: color 0.15s;
145
+ }
146
+ .rw-ft-col a:hover,
147
+ :global(:where(.rw-ft) a:hover) {
148
+ color: var(--rw-primary);
149
+ }
150
+ .rw-ft-bottom {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ gap: 8px 14px;
154
+ align-items: center;
155
+ margin-top: 44px;
156
+ padding: 16px 0;
157
+ border-top: 1px solid var(--rw-outline);
158
+ font-family: var(--rw-font-mono);
159
+ font-size: 12px;
160
+ color: var(--rw-on-surface-faint);
161
+ }
162
+ .rw-ft-bottom :global(.sep) {
163
+ opacity: 0.4;
164
+ }
165
+ </style>
@@ -2,21 +2,28 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
+ import type { Component, Snippet } from "svelte";
5
6
  import Modal from "./Modal.svelte";
6
7
  import Button from "./Button.svelte";
7
-
8
- type AlertVariant = "danger" | "warning" | "info";
8
+ import type { ButtonVariant } from "./button-styles.js";
9
9
 
10
10
  interface Props {
11
11
  open?: boolean;
12
12
  title: string;
13
13
  description?: string | undefined;
14
+ size?: "sm" | "md" | "lg" | "xl" | "full";
14
15
  confirmLabel?: string;
15
16
  cancelLabel?: string;
16
- variant?: AlertVariant;
17
- onConfirm?: () => void;
17
+ confirmVariant?: ButtonVariant;
18
+ confirmDisabled?: boolean;
19
+ confirmIcon?: Component<{ size?: number }>;
20
+ /** May be async: the dialog shows a spinner and closes once it resolves.
21
+ * If it throws, the dialog stays open so the caller can surface the error. */
22
+ onConfirm?: () => void | Promise<void>;
18
23
  onCancel?: () => void;
19
- loading?: boolean;
24
+ children?: Snippet;
25
+ /** Replaces the default Cancel / Confirm buttons. */
26
+ footer?: Snippet;
20
27
  class?: string;
21
28
  }
22
29
 
@@ -24,40 +31,71 @@
24
31
  open = $bindable(false),
25
32
  title,
26
33
  description,
34
+ size = "sm",
27
35
  confirmLabel = "Confirm",
28
36
  cancelLabel = "Cancel",
29
- variant = "danger",
37
+ confirmVariant = "danger",
38
+ confirmDisabled = false,
39
+ confirmIcon: ConfirmIcon,
30
40
  onConfirm,
31
41
  onCancel,
32
- loading = false,
42
+ children,
43
+ footer: footerProp,
33
44
  class: className = "",
34
45
  }: Props = $props();
35
46
 
36
- const confirmVariantMap: Record<AlertVariant, "danger" | "primary"> = {
37
- danger: "danger",
38
- warning: "primary",
39
- info: "primary",
40
- };
47
+ let confirming = $state(false);
48
+
49
+ async function handleConfirm() {
50
+ if (confirming) return;
51
+ confirming = true;
52
+ try {
53
+ await onConfirm?.();
54
+ open = false;
55
+ } finally {
56
+ confirming = false;
57
+ }
58
+ }
41
59
 
42
60
  function handleCancel() {
61
+ if (confirming) return;
43
62
  open = false;
44
63
  onCancel?.();
45
64
  }
46
-
47
- function handleConfirm() {
48
- onConfirm?.();
49
- }
50
65
  </script>
51
66
 
52
- <Modal bind:open {title} {description} size="sm" closable onClose={handleCancel} class={className}>
67
+ {#snippet confirmIconSnippet()}
68
+ {#if ConfirmIcon}<ConfirmIcon size={16} />{/if}
69
+ {/snippet}
70
+
71
+ <Modal
72
+ bind:open
73
+ {title}
74
+ {description}
75
+ {size}
76
+ closable={!confirming}
77
+ onClose={handleCancel}
78
+ {children}
79
+ class={className}
80
+ >
53
81
  {#snippet footer()}
54
- <div class="flex items-center justify-end gap-3">
55
- <Button variant="secondary" onclick={handleCancel} disabled={loading}>
56
- {cancelLabel}
57
- </Button>
58
- <Button variant={confirmVariantMap[variant]} onclick={handleConfirm} {loading}>
59
- {confirmLabel}
60
- </Button>
61
- </div>
82
+ {#if footerProp}
83
+ {@render footerProp()}
84
+ {:else}
85
+ <div class="flex items-center justify-end gap-3">
86
+ <Button variant="secondary" onclick={handleCancel} disabled={confirming}>
87
+ {cancelLabel}
88
+ </Button>
89
+ <Button
90
+ variant={confirmVariant}
91
+ loading={confirming}
92
+ disabled={confirming || confirmDisabled}
93
+ onclick={() => void handleConfirm()}
94
+ icon={ConfirmIcon ? confirmIconSnippet : undefined}
95
+ >
96
+ {confirmLabel}
97
+ </Button>
98
+ </div>
99
+ {/if}
62
100
  {/snippet}
63
101
  </Modal>
@@ -3,9 +3,11 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import Tooltip from "./Tooltip.svelte";
6
7
 
7
8
  type BadgeVariant = "default" | "primary" | "success" | "warning" | "danger" | "info";
8
9
  type BadgeSize = "sm" | "md" | "lg";
10
+ type TooltipPosition = "top" | "bottom" | "left" | "right";
9
11
 
10
12
  interface Props {
11
13
  variant?: BadgeVariant;
@@ -13,6 +15,10 @@
13
15
  dot?: boolean;
14
16
  loading?: boolean;
15
17
  outline?: boolean;
18
+ /** Shown in the styled hover tooltip (see {@link Tooltip}). */
19
+ tooltip?: string;
20
+ /** Side the tooltip opens on — pick one that has room, e.g. "bottom" near the top of the viewport. */
21
+ tooltipPosition?: TooltipPosition;
16
22
  children?: Snippet;
17
23
  class?: string;
18
24
  }
@@ -23,6 +29,8 @@
23
29
  dot = false,
24
30
  loading = false,
25
31
  outline = false,
32
+ tooltip,
33
+ tooltipPosition = "top",
26
34
  children,
27
35
  class: className = "",
28
36
  }: Props = $props();
@@ -91,37 +99,47 @@
91
99
  };
92
100
  </script>
93
101
 
94
- <span
95
- class="{baseClasses} {outline
96
- ? variantClasses[variant].outline
97
- : variantClasses[variant].solid} {sizeClasses[size]} {className}"
98
- >
99
- {#if dot}
100
- {#if loading}
101
- <svg
102
- class="{spinnerSizeClasses[size]} animate-spin {indicatorTextColors[variant]}"
103
- viewBox="0 0 24 24"
104
- fill="none"
105
- >
106
- <circle
107
- class="opacity-25"
108
- cx="12"
109
- cy="12"
110
- r="10"
111
- stroke="currentColor"
112
- stroke-width="4"
113
- ></circle>
114
- <path
115
- class="opacity-75"
116
- fill="currentColor"
117
- 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"
118
- ></path>
119
- </svg>
120
- {:else}
121
- <span class="h-1.5 w-1.5 rounded-full {dotColors[variant]}"></span>
102
+ {#snippet chip(extraClass: string)}
103
+ <span
104
+ class="{baseClasses} {outline
105
+ ? variantClasses[variant].outline
106
+ : variantClasses[variant].solid} {sizeClasses[size]} {extraClass}"
107
+ >
108
+ {#if dot}
109
+ {#if loading}
110
+ <svg
111
+ class="{spinnerSizeClasses[size]} animate-spin {indicatorTextColors[variant]}"
112
+ viewBox="0 0 24 24"
113
+ fill="none"
114
+ >
115
+ <circle
116
+ class="opacity-25"
117
+ cx="12"
118
+ cy="12"
119
+ r="10"
120
+ stroke="currentColor"
121
+ stroke-width="4"
122
+ ></circle>
123
+ <path
124
+ class="opacity-75"
125
+ fill="currentColor"
126
+ 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"
127
+ ></path>
128
+ </svg>
129
+ {:else}
130
+ <span class="h-1.5 w-1.5 rounded-full {dotColors[variant]}"></span>
131
+ {/if}
122
132
  {/if}
123
- {/if}
124
- {#if children}
125
- {@render children()}
126
- {/if}
127
- </span>
133
+ {#if children}
134
+ {@render children()}
135
+ {/if}
136
+ </span>
137
+ {/snippet}
138
+
139
+ {#if tooltip}
140
+ <Tooltip content={tooltip} position={tooltipPosition} class={className} wide>
141
+ {@render chip("")}
142
+ </Tooltip>
143
+ {:else}
144
+ {@render chip(className)}
145
+ {/if}
@@ -17,7 +17,7 @@
17
17
  size?: ButtonSize;
18
18
  fullWidth?: boolean;
19
19
  loading?: boolean;
20
- icon?: Snippet;
20
+ icon?: Snippet | undefined;
21
21
  iconRight?: Snippet;
22
22
  children?: Snippet;
23
23
  }