@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
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.4.0",
4
+ "version": "1.5.1",
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.4.0",
53
- "@runwisp/typescript-config": "1.4.0",
52
+ "@runwisp/eslint-config": "1.5.1",
53
+ "@runwisp/typescript-config": "1.5.1",
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,12 +93,11 @@
93
93
  "@fontsource-variable/geist-mono": "^5.3.0",
94
94
  "@fontsource/tasa-orbiter": "^5.3.0",
95
95
  "@lucide/svelte": "^1.52.0",
96
- "@runwisp/common": "1.4.0",
96
+ "@runwisp/common": "1.5.1",
97
97
  "@tanstack/svelte-virtual": "^3.13.35",
98
98
  "ansi-to-html": "^0.7.2",
99
99
  "cron-parser": "^5.7.0",
100
100
  "cronstrue": "^3.24.0",
101
- "fuse.js": "^7.5.0",
102
101
  "ms": "^2.1.3"
103
102
  }
104
103
  }
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { Placement } from "@floating-ui/dom";
5
+ import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
6
+
7
+ export interface FloatingOptions {
8
+ reference: HTMLElement | null;
9
+ placement: Placement;
10
+ offset: number;
11
+ padding: number;
12
+ /** Size the floating element to the reference's width (e.g. a select menu). */
13
+ matchWidth?: boolean;
14
+ /** When true the element is left unpositioned (e.g. shown as a bottom sheet). */
15
+ disabled?: boolean;
16
+ /** Called with the final placement after flip(), e.g. to set a transform origin. */
17
+ onPlace?: (placement: Placement) => void;
18
+ }
19
+
20
+ /**
21
+ * Svelte action that keeps `node` positioned against `options.reference`
22
+ * while mounted, re-computing on scroll, resize and layout changes.
23
+ */
24
+ export function floating(node: HTMLElement, options: FloatingOptions) {
25
+ let stop: (() => void) | null = null;
26
+
27
+ function start(opts: FloatingOptions) {
28
+ stop?.();
29
+ stop = null;
30
+ const reference = opts.reference;
31
+ if (opts.disabled === true || !reference) {
32
+ node.style.removeProperty("left");
33
+ node.style.removeProperty("top");
34
+ node.style.removeProperty("position");
35
+ return;
36
+ }
37
+ if (opts.matchWidth === true) node.style.width = `${String(reference.offsetWidth)}px`;
38
+ // A position computed after stop() must not land on a re-purposed node.
39
+ let live = true;
40
+ const cleanup = autoUpdate(reference, node, () => {
41
+ void computePosition(reference, node, {
42
+ placement: opts.placement,
43
+ middleware: [offset(opts.offset), flip(), shift({ padding: opts.padding })],
44
+ }).then(({ x, y, placement }) => {
45
+ if (!live) return;
46
+ Object.assign(node.style, {
47
+ left: `${String(x)}px`,
48
+ top: `${String(y)}px`,
49
+ position: "absolute",
50
+ });
51
+ opts.onPlace?.(placement);
52
+ });
53
+ });
54
+ stop = () => {
55
+ live = false;
56
+ cleanup();
57
+ };
58
+ }
59
+
60
+ start(options);
61
+ return {
62
+ update: start,
63
+ destroy() {
64
+ stop?.();
65
+ },
66
+ };
67
+ }
68
+
69
+ /** Whether a click landed outside every given element (null entries are ignored). */
70
+ export function isOutsideClick(e: MouseEvent, ...elements: (HTMLElement | null)[]): boolean {
71
+ const path = e.composedPath();
72
+ return !elements.some((el) => el !== null && path.includes(el));
73
+ }
@@ -25,7 +25,7 @@ export function getFocusableElements(container: HTMLElement): HTMLElement[] {
25
25
  * Pure decision function: given a keydown event, the currently focusable
26
26
  * elements (in order), and whatever currently has focus, returns the element
27
27
  * that Tab/Shift+Tab should wrap focus to in order to keep it trapped inside
28
- * the set — or `null` if the key doesn't need handling.
28
+ * the set, or `null` if the key doesn't need handling.
29
29
  *
30
30
  * Framework- and DOM-free on purpose (elements are compared by identity only)
31
31
  * so this can be unit tested without a browser or jsdom.
@@ -59,7 +59,7 @@ export interface TrapFocusOptions {
59
59
  * Svelte action that keeps keyboard focus inside `node` while it's mounted:
60
60
  * Tab from the last focusable descendant wraps to the first (and Shift+Tab
61
61
  * from the first wraps to the last), and focus is restored to whatever was
62
- * focused beforehand once the trap is torn down — unless a click on some
62
+ * focused beforehand once the trap is torn down, unless a click on some
63
63
  * other real element already moved focus there, in which case that's left
64
64
  * alone.
65
65
  */
@@ -97,7 +97,7 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
97
97
  destroy() {
98
98
  node.removeEventListener("keydown", handleKeydown);
99
99
  // By the time an action's destroy() runs, `node` (and whatever
100
- // inside it held focus) has already been detached — removing a
100
+ // inside it held focus) has already been detached, removing a
101
101
  // focused element resets focus to <body> as a browser default, so
102
102
  // that can't be told apart from an explicit close (Escape, item
103
103
  // activation) by checking `node.contains(activeElement)` here.
@@ -1,8 +1,12 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ // Inside a modal <dialog> the rest of the page is inert and below the top
5
+ // layer, so content opened from it (a menu, a popover) is moved into the
6
+ // dialog instead of <body>.
4
7
  export function portal(node: HTMLElement) {
5
- document.body.appendChild(node);
8
+ const host = node.parentElement?.closest("dialog[open]") ?? document.body;
9
+ host.appendChild(node);
6
10
  return {
7
11
  destroy() {
8
12
  node.remove();
@@ -2,18 +2,22 @@
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 { ACCORDION_KEY, AccordionGroup } from "./accordion.svelte.js";
6
7
 
7
8
  interface Props {
9
+ /** `single` keeps at most one item open; `multiple` lets items open independently. */
8
10
  type?: "single" | "multiple";
9
11
  children?: Snippet;
10
12
  class?: string;
11
13
  }
12
14
 
13
15
  let { type = "multiple", children, class: className = "" }: Props = $props();
16
+
17
+ setContext(ACCORDION_KEY, new AccordionGroup(() => type === "single"));
14
18
  </script>
15
19
 
16
- <div class="divide-y divide-outline {className}" role="presentation" data-accordion-type={type}>
20
+ <div class="divide-y divide-outline {className}">
17
21
  {#if children}
18
22
  {@render children()}
19
23
  {/if}
@@ -2,62 +2,119 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
- import type { Snippet } from "svelte";
5
+ import { getContext, untrack, type Snippet } from "svelte";
6
6
  import { ChevronDown } from "@lucide/svelte";
7
+ import { ACCORDION_KEY, type AccordionGroup } from "./accordion.svelte.js";
8
+
9
+ /** `framed` is a standalone bordered panel; `plain` is a row inside an Accordion. */
10
+ type AccordionItemVariant = "plain" | "framed";
7
11
 
8
12
  interface Props {
9
13
  title?: Snippet | string;
14
+ /** Controls beside the header that must not toggle it (a switch, a delete button). */
15
+ actions?: Snippet;
10
16
  children?: Snippet;
11
17
  open?: boolean;
12
18
  disabled?: boolean;
19
+ variant?: AccordionItemVariant;
13
20
  class?: string;
14
21
  }
15
22
 
16
23
  let {
17
24
  title,
25
+ actions,
18
26
  children,
19
27
  open = $bindable(false),
20
28
  disabled = false,
29
+ variant = "plain",
21
30
  class: className = "",
22
31
  }: Props = $props();
23
32
 
33
+ const id = $props.id();
34
+ const group = getContext<AccordionGroup | undefined>(ACCORDION_KEY);
35
+
36
+ // Single mode: claim the group when this item opens (by click or binding)…
37
+ $effect(() => {
38
+ if (open && group?.single) {
39
+ untrack(() => {
40
+ group.active = id;
41
+ });
42
+ }
43
+ });
44
+
45
+ // …and close when another item claims it.
46
+ $effect(() => {
47
+ const active = group?.active;
48
+ if (active && active !== id) {
49
+ untrack(() => {
50
+ if (open) open = false;
51
+ });
52
+ }
53
+ });
54
+
24
55
  function toggle() {
25
56
  if (!disabled) {
26
57
  open = !open;
27
58
  }
28
59
  }
60
+
61
+ const framed = $derived(variant === "framed");
29
62
  </script>
30
63
 
31
- <div class="group {className}">
32
- <button
33
- type="button"
34
- onclick={toggle}
35
- {disabled}
36
- class="
37
- flex w-full items-center justify-between gap-3 py-4 text-left font-mono text-sm
38
- text-on-surface hover:text-primary
39
- disabled:cursor-not-allowed disabled:opacity-50
40
- "
41
- aria-expanded={open}
42
- >
43
- <span class="flex-1">
44
- {#if typeof title === "function"}
45
- {@render title()}
46
- {:else if title}
47
- {title}
48
- {/if}
49
- </span>
50
- <ChevronDown size={16} class="shrink-0 text-on-surface-faint {open ? 'rotate-180' : ''}" />
51
- </button>
64
+ <div
65
+ class="group {framed
66
+ ? 'rounded-[4px] border border-outline bg-surface-raised'
67
+ : ''} {className}"
68
+ >
69
+ <div class="flex items-center gap-3 {framed ? 'px-4' : ''}">
70
+ <button
71
+ type="button"
72
+ onclick={toggle}
73
+ {disabled}
74
+ class="
75
+ flex min-w-0 flex-1 items-center justify-between gap-3 text-left font-mono text-sm
76
+ text-on-surface hover:text-primary
77
+ disabled:cursor-not-allowed disabled:opacity-50
78
+ {framed ? 'py-3' : 'py-4'}
79
+ "
80
+ aria-expanded={open}
81
+ aria-controls="{id}-content"
82
+ >
83
+ <span class="min-w-0 flex-1">
84
+ {#if typeof title === "function"}
85
+ {@render title()}
86
+ {:else if title}
87
+ {title}
88
+ {/if}
89
+ </span>
90
+ <ChevronDown
91
+ size={16}
92
+ class="shrink-0 text-on-surface-faint {open ? 'rotate-180' : ''}"
93
+ />
94
+ </button>
95
+ {#if actions}
96
+ <div class="flex shrink-0 items-center gap-2">
97
+ {@render actions()}
98
+ </div>
99
+ {/if}
100
+ </div>
52
101
 
102
+ <!-- Collapsed content stays mounted for the height transition, so `inert`
103
+ keeps it out of the tab order and the accessibility tree. -->
53
104
  <div
105
+ id="{id}-content"
106
+ inert={!open}
54
107
  class="grid transition-[grid-template-rows] duration-150 ease-out {open
55
108
  ? 'grid-rows-[1fr]'
56
109
  : 'grid-rows-[0fr]'}"
57
110
  >
58
111
  <div class="overflow-hidden">
59
112
  {#if children}
60
- <div class="pb-4 text-sm text-on-surface-muted">
113
+ <div
114
+ class="text-sm text-on-surface-muted {framed
115
+ ? 'border-t border-outline-faint p-4'
116
+ : 'pb-4'}"
117
+ >
61
118
  {@render children()}
62
119
  </div>
63
120
  {/if}
@@ -44,7 +44,6 @@
44
44
  xl: "h-4 w-4",
45
45
  };
46
46
 
47
- // Generate a consistent color based on name
48
47
  const colors = [
49
48
  "bg-primary",
50
49
  "bg-info",
@@ -17,7 +17,7 @@
17
17
  outline?: boolean;
18
18
  /** Shown in the styled hover tooltip (see {@link Tooltip}). */
19
19
  tooltip?: string;
20
- /** Side the tooltip opens on — pick one that has room, e.g. "bottom" near the top of the viewport. */
20
+ /** Side the tooltip opens on, pick one that has room, e.g. "bottom" near the top of the viewport. */
21
21
  tooltipPosition?: TooltipPosition;
22
22
  children?: Snippet;
23
23
  class?: string;
@@ -2,59 +2,108 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import { CopyFeedback } from "../utils/clipboard.svelte.js";
7
+
8
+ type CodeBlockVariant = "terminal" | "surface";
9
+ type CodeBlockSize = "sm" | "md";
10
+
5
11
  interface Props {
6
- code: string;
12
+ /** Plain source. Rendered unless `children` is set, and always what the copy button copies. */
13
+ code?: string;
14
+ /** Rich or highlighted markup. Without `code`, the copy button copies its rendered text. */
15
+ children?: Snippet;
7
16
  language?: string;
8
17
  filename?: string;
18
+ /** Shell prompt before the first line, e.g. "$". Never copied or selected. */
19
+ prompt?: string;
20
+ /** `terminal` stays dark in both themes; `surface` follows the page theme for in-page snippets. */
21
+ variant?: CodeBlockVariant;
22
+ size?: CodeBlockSize;
23
+ copyable?: boolean;
9
24
  class?: string;
10
25
  }
11
26
 
12
- let { code, language, filename, class: className = "" }: Props = $props();
13
-
14
- let copied = $state(false);
15
- let resetTimer = $state<ReturnType<typeof setTimeout> | undefined>(undefined);
16
-
17
- async function writeClipboard(): Promise<void> {
18
- if (typeof navigator === "undefined" || !navigator.clipboard) return;
19
- try {
20
- await navigator.clipboard.writeText(code);
21
- copied = true;
22
- if (resetTimer) clearTimeout(resetTimer);
23
- resetTimer = setTimeout(() => {
24
- copied = false;
25
- }, 2000);
26
- } catch {
27
- copied = false;
28
- }
29
- }
27
+ let {
28
+ code,
29
+ children,
30
+ language,
31
+ filename,
32
+ prompt,
33
+ variant = "terminal",
34
+ size = "md",
35
+ copyable = true,
36
+ class: className = "",
37
+ }: Props = $props();
38
+
39
+ const feedback = new CopyFeedback();
40
+ let codeEl = $state<HTMLElement | undefined>(undefined);
30
41
 
31
42
  function handleCopy(): void {
32
- void writeClipboard();
43
+ void feedback.copy(code ?? codeEl?.textContent ?? "");
33
44
  }
45
+
46
+ const variantClasses: Record<
47
+ CodeBlockVariant,
48
+ { frame: string; header: string; prompt: string; button: string }
49
+ > = {
50
+ terminal: {
51
+ frame: "border-term-line bg-term text-term-text",
52
+ header: "border-term-line-2 bg-term-2 text-term-muted",
53
+ prompt: "text-term-teal",
54
+ button: "border-term-line-2 bg-term-2 text-term-muted hover:border-term-teal hover:text-term-teal",
55
+ },
56
+ surface: {
57
+ frame: "border-outline bg-surface-sunken text-on-surface-muted",
58
+ header: "border-outline bg-surface text-on-surface-faint",
59
+ prompt: "text-on-surface-faint",
60
+ button: "border-outline bg-surface-raised text-on-surface-faint hover:border-outline-hover hover:text-primary",
61
+ },
62
+ };
63
+
64
+ const sizeClasses: Record<CodeBlockSize, string> = {
65
+ sm: "px-4 py-3 text-xs",
66
+ md: "px-4 py-4 text-sm leading-relaxed",
67
+ };
68
+
69
+ const tone = $derived(variantClasses[variant]);
70
+ const hasHeader = $derived(Boolean(filename || language));
34
71
  </script>
35
72
 
36
- <div
37
- class="overflow-hidden rounded-[4px] border border-term-line bg-term text-term-text {className}"
38
- >
39
- {#if filename || language}
73
+ {#snippet copyButton(extra: string)}
74
+ <button
75
+ type="button"
76
+ onclick={handleCopy}
77
+ class="inline-flex items-center rounded-[3px] border px-2 py-1 font-mono text-xs {tone.button} {extra}"
78
+ aria-label={feedback.copied ? "Copied" : "Copy to clipboard"}
79
+ >
80
+ {feedback.copied ? "[copied]" : "[copy]"}
81
+ </button>
82
+ {/snippet}
83
+
84
+ <div class="overflow-hidden rounded-[4px] border {tone.frame} {className}">
85
+ {#if hasHeader}
40
86
  <div
41
- class="flex items-center justify-between border-b border-term-line-2 bg-term-2 px-4 py-2 font-mono text-xs text-term-muted"
87
+ class="flex items-center justify-between gap-3 border-b px-4 py-2 font-mono text-xs {tone.header}"
42
88
  >
43
89
  <span>{filename ?? language}</span>
90
+ {#if copyable}
91
+ {@render copyButton("-my-1")}
92
+ {/if}
44
93
  </div>
45
94
  {/if}
46
95
  <div class="relative">
47
96
  <pre
48
- class="overflow-x-auto px-4 py-4 font-mono text-sm leading-relaxed text-term-text"><code
49
- >{code}</code
97
+ class="overflow-x-auto font-mono {sizeClasses[size]} {copyable && !hasHeader
98
+ ? 'pr-24'
99
+ : ''}">{#if prompt}<span
100
+ class="mr-[1ch] select-none {tone.prompt}"
101
+ aria-hidden="true">{prompt}</span
102
+ >{/if}<code bind:this={codeEl}
103
+ >{#if children}{@render children()}{:else}{code}{/if}</code
50
104
  ></pre>
51
- <button
52
- type="button"
53
- onclick={handleCopy}
54
- class="absolute top-2 right-2 inline-flex items-center rounded-[3px] border border-term-line-2 bg-term-2 px-2 py-1 font-mono text-xs text-term-muted hover:border-term-teal hover:text-term-teal"
55
- aria-label={copied ? "Copied" : "Copy to clipboard"}
56
- >
57
- {copied ? "[copied]" : "[copy]"}
58
- </button>
105
+ {#if copyable && !hasHeader}
106
+ {@render copyButton("absolute top-2 right-2")}
107
+ {/if}
59
108
  </div>
60
109
  </div>
@@ -8,10 +8,13 @@
8
8
  import cronstrue from "cronstrue";
9
9
  import { CronExpressionParser } from "cron-parser";
10
10
 
11
- let { value = $bindable(), disabled = false } = $props<{
11
+ let {
12
+ value = $bindable(),
13
+ disabled = false,
14
+ }: {
12
15
  value: string;
13
16
  disabled?: boolean;
14
- }>();
17
+ } = $props();
15
18
 
16
19
  let examplesModalOpen = $state(false);
17
20