@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.
- package/package.json +4 -5
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/focusTrap.ts +3 -3
- package/src/lib/actions/portal.ts +5 -1
- package/src/lib/components/Accordion.svelte +6 -2
- package/src/lib/components/AccordionItem.svelte +80 -23
- package/src/lib/components/Avatar.svelte +0 -1
- package/src/lib/components/Badge.svelte +1 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +100 -138
- package/src/lib/components/Drawer.svelte +2 -6
- package/src/lib/components/Dropdown.svelte +19 -57
- package/src/lib/components/EmptyState.svelte +2 -1
- package/src/lib/components/ErrorState.svelte +2 -1
- package/src/lib/components/FilterBar.svelte +1 -1
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Heading.svelte +19 -12
- package/src/lib/components/Kbd.svelte +25 -10
- package/src/lib/components/Link.svelte +7 -1
- package/src/lib/components/LogConsole.svelte +40 -42
- package/src/lib/components/Modal.svelte +44 -33
- package/src/lib/components/NotificationBell.svelte +2 -1
- package/src/lib/components/PageContainer.svelte +1 -10
- package/src/lib/components/PageHeader.svelte +8 -4
- package/src/lib/components/Popover.svelte +9 -51
- package/src/lib/components/Progress.svelte +19 -6
- package/src/lib/components/Radio.svelte +16 -4
- package/src/lib/components/RadioGroup.svelte +15 -2
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +16 -47
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/Table.svelte +13 -4
- package/src/lib/components/TableBody.svelte +2 -1
- package/src/lib/components/TableCell.svelte +38 -8
- package/src/lib/components/TableHead.svelte +12 -4
- package/src/lib/components/TableRow.svelte +23 -3
- package/src/lib/components/TaskCard.svelte +1 -1
- package/src/lib/components/TimezoneSelect.svelte +2 -2
- package/src/lib/components/Tooltip.svelte +1 -1
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
- package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
- package/src/lib/components/dashboard/RunsList.svelte +17 -26
- package/src/lib/components/dashboard/cron-format.test.ts +1 -1
- package/src/lib/components/dashboard/cron-format.ts +3 -3
- package/src/lib/components/dashboard/run-filters.test.ts +4 -60
- package/src/lib/components/dashboard/run-filters.ts +29 -144
- package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
- package/src/lib/components/dashboard/run-helpers.ts +40 -39
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/ansi.ts +1 -1
- package/src/lib/log-console/highlight-scroll.test.ts +59 -0
- package/src/lib/log-console/highlight-scroll.ts +41 -0
- package/src/lib/theme-tokens.css +21 -21
- package/src/lib/theme.css +22 -16
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.test.ts +3 -3
- package/src/lib/utils/format.ts +65 -94
- package/src/lib/utils/id.test.ts +3 -7
- package/src/lib/utils/id.ts +3 -2
- package/src/lib/utils/theme.svelte.test.ts +0 -6
- package/src/lib/utils/theme.svelte.ts +3 -5
- package/src/lib/utils/toast.svelte.ts +5 -14
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +0 -5
- package/src/stories/Heading.stories.svelte +31 -0
- package/src/stories/Kbd.stories.svelte +36 -0
- package/src/stories/Link.stories.svelte +38 -0
- package/src/stories/LogConsole.stories.svelte +2 -2
- package/src/stories/PageHeader.stories.svelte +31 -0
- package/src/stories/Progress.stories.svelte +26 -0
- package/src/stories/RadioGroup.stories.svelte +43 -0
- package/src/stories/Table.stories.svelte +69 -0
- package/src/svelte-shims.d.ts +0 -5
- 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
|
+
"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.
|
|
53
|
-
"@runwisp/typescript-config": "1.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}"
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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}
|
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 {
|
|
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
|
|