@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.
- package/package.json +5 -7
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +3 -24
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +2 -6
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +95 -418
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +265 -543
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +9 -0
- package/src/lib/components/dashboard/cron-format.ts +5 -1
- package/src/lib/components/dashboard/run-filters.test.ts +60 -0
- package/src/lib/components/dashboard/run-filters.ts +78 -6
- package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
- package/src/lib/components/dashboard/run-helpers.ts +122 -6
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/utils/format.test.ts +122 -0
- package/src/lib/utils/format.ts +97 -4
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/Tabs.stories.svelte +17 -0
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.7.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.
|
|
53
|
-
"@runwisp/typescript-config": "1.
|
|
52
|
+
"@runwisp/eslint-config": "1.7.0",
|
|
53
|
+
"@runwisp/typescript-config": "1.7.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",
|
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
"@tailwindcss/forms": "^0.5.11",
|
|
61
61
|
"@tailwindcss/typography": "^0.5.20",
|
|
62
62
|
"@tailwindcss/vite": "^4.3.3",
|
|
63
|
-
"@types/ms": "^2.1.0",
|
|
64
63
|
"@types/node": "^26.1.2",
|
|
65
64
|
"eslint": "^10.8.0",
|
|
66
65
|
"eslint-config-prettier": "^10.1.8",
|
|
@@ -93,11 +92,10 @@
|
|
|
93
92
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
94
93
|
"@fontsource/tasa-orbiter": "^5.3.0",
|
|
95
94
|
"@lucide/svelte": "^1.52.0",
|
|
96
|
-
"@runwisp/common": "1.
|
|
95
|
+
"@runwisp/common": "1.7.0",
|
|
97
96
|
"@tanstack/svelte-virtual": "^3.13.35",
|
|
98
97
|
"ansi-to-html": "^0.7.2",
|
|
99
98
|
"cron-parser": "^5.7.0",
|
|
100
|
-
"cronstrue": "^3.24.0"
|
|
101
|
-
"ms": "^2.1.3"
|
|
99
|
+
"cronstrue": "^3.24.0"
|
|
102
100
|
}
|
|
103
101
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { foldUntilFits } from "./fold-to-fit.js";
|
|
6
|
+
|
|
7
|
+
// A row of named parts with widths, folding parts to zero.
|
|
8
|
+
function row(width: number, parts: Record<string, number>) {
|
|
9
|
+
const shown = { ...parts };
|
|
10
|
+
return {
|
|
11
|
+
fits: () => Object.values(shown).reduce((a, b) => a + b, 0) <= width,
|
|
12
|
+
fold: (key: string) => {
|
|
13
|
+
shown[key] = 0;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("foldUntilFits", () => {
|
|
19
|
+
const order = ["id", "via", "start", "usage"];
|
|
20
|
+
const parts = { verdict: 100, id: 60, via: 40, start: 80, usage: 90 };
|
|
21
|
+
|
|
22
|
+
it("folds nothing when the row fits", () => {
|
|
23
|
+
const r = row(400, parts);
|
|
24
|
+
expect(foldUntilFits(order, r.fits, r.fold)).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("folds in order and stops as soon as it fits", () => {
|
|
28
|
+
const r = row(280, parts);
|
|
29
|
+
expect(foldUntilFits(order, r.fits, r.fold)).toEqual(["id", "via"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("folds everything foldable when even that doesn't fit", () => {
|
|
33
|
+
const r = row(50, parts);
|
|
34
|
+
expect(foldUntilFits(order, r.fits, r.fold)).toEqual(order);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Folds keys away, in `order`, until `fits()` says the row fits. Returns the
|
|
6
|
+
* keys it folded. Fixed order, so a control is always in the same place: in
|
|
7
|
+
* the row, or in its overflow menu.
|
|
8
|
+
*/
|
|
9
|
+
export function foldUntilFits(
|
|
10
|
+
order: readonly string[],
|
|
11
|
+
fits: () => boolean,
|
|
12
|
+
fold: (key: string) => void,
|
|
13
|
+
): string[] {
|
|
14
|
+
const folded: string[] = [];
|
|
15
|
+
for (const key of order) {
|
|
16
|
+
if (fits()) break;
|
|
17
|
+
fold(key);
|
|
18
|
+
folded.push(key);
|
|
19
|
+
}
|
|
20
|
+
return folded;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FoldToFitOptions {
|
|
24
|
+
/** Fold keys, first to go first. */
|
|
25
|
+
order: readonly string[];
|
|
26
|
+
/** Called with the folded keys whenever they change, to fill a ⋯ menu. */
|
|
27
|
+
onfold?: (folded: string[]) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keeps a one-line row from overflowing by folding parts of it away. Markup:
|
|
32
|
+
* `[data-fold=k]` hides when `k` folds, `[data-fold-alt=k]` (render it with
|
|
33
|
+
* `hidden`) shows instead, a shorter version. `[data-fold-shrink]` parts
|
|
34
|
+
* normally truncate, so they stop shrinking while the row is measured.
|
|
35
|
+
* Re-fits when the row resizes or its content changes.
|
|
36
|
+
*/
|
|
37
|
+
export function foldToFit(row: HTMLElement, options: FoldToFitOptions) {
|
|
38
|
+
let opts = options;
|
|
39
|
+
let reported = "";
|
|
40
|
+
let frame = 0;
|
|
41
|
+
|
|
42
|
+
const all = (selector: string) => Array.from(row.querySelectorAll<HTMLElement>(selector));
|
|
43
|
+
const setFolded = (key: string, folded: boolean) => {
|
|
44
|
+
for (const el of all(`[data-fold="${key}"]`)) el.hidden = folded;
|
|
45
|
+
for (const el of all(`[data-fold-alt="${key}"]`)) el.hidden = !folded;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function fit() {
|
|
49
|
+
frame = 0;
|
|
50
|
+
for (const key of opts.order) setFolded(key, false);
|
|
51
|
+
const shrinking = all("[data-fold-shrink]");
|
|
52
|
+
for (const el of shrinking) el.style.flexShrink = "0";
|
|
53
|
+
const folded = foldUntilFits(
|
|
54
|
+
opts.order,
|
|
55
|
+
() => row.scrollWidth <= row.clientWidth,
|
|
56
|
+
(key) => {
|
|
57
|
+
setFolded(key, true);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
for (const el of shrinking) el.style.flexShrink = "";
|
|
61
|
+
const key = folded.join(" ");
|
|
62
|
+
if (key !== reported) {
|
|
63
|
+
reported = key;
|
|
64
|
+
opts.onfold?.(folded);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const schedule = () => {
|
|
69
|
+
if (frame === 0) frame = requestAnimationFrame(fit);
|
|
70
|
+
};
|
|
71
|
+
const resize = new ResizeObserver(schedule);
|
|
72
|
+
resize.observe(row);
|
|
73
|
+
// Not attributes: fitting toggles `hidden`, which would loop.
|
|
74
|
+
const content = new MutationObserver(schedule);
|
|
75
|
+
content.observe(row, { childList: true, subtree: true, characterData: true });
|
|
76
|
+
fit();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
update(next: FoldToFitOptions) {
|
|
80
|
+
opts = next;
|
|
81
|
+
schedule();
|
|
82
|
+
},
|
|
83
|
+
destroy() {
|
|
84
|
+
cancelAnimationFrame(frame);
|
|
85
|
+
resize.disconnect();
|
|
86
|
+
content.disconnect();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { modalDialogHandlers } from "./modal-dialog.js";
|
|
6
|
+
|
|
7
|
+
function setup(closable: boolean, open = true) {
|
|
8
|
+
const close = vi.fn();
|
|
9
|
+
const handlers = modalDialogHandlers({ closable: () => closable, isOpen: () => open, close });
|
|
10
|
+
const dialog = new EventTarget();
|
|
11
|
+
return { close, handlers, dialog, showModal: vi.fn() };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("modalDialogHandlers", () => {
|
|
15
|
+
it("closes on Escape only when closable, and always cancels the native close", () => {
|
|
16
|
+
const closable = setup(true);
|
|
17
|
+
const event = { preventDefault: vi.fn() };
|
|
18
|
+
closable.handlers.oncancel(event);
|
|
19
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
20
|
+
expect(closable.close).toHaveBeenCalledOnce();
|
|
21
|
+
|
|
22
|
+
const locked = setup(false);
|
|
23
|
+
locked.handlers.oncancel(event);
|
|
24
|
+
expect(locked.close).not.toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("closes on a press that starts and ends on the backdrop", () => {
|
|
28
|
+
const { handlers, close, dialog } = setup(true);
|
|
29
|
+
handlers.onpointerdown({ target: new EventTarget(), currentTarget: dialog });
|
|
30
|
+
handlers.onclick({ target: dialog, currentTarget: dialog });
|
|
31
|
+
expect(close).not.toHaveBeenCalled();
|
|
32
|
+
|
|
33
|
+
handlers.onpointerdown({ target: dialog, currentTarget: dialog });
|
|
34
|
+
handlers.onclick({ target: dialog, currentTarget: dialog });
|
|
35
|
+
expect(close).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("reopens a dialog the browser closed while it is still mounted", () => {
|
|
39
|
+
const mounted = setup(false, true);
|
|
40
|
+
mounted.handlers.onclose({ currentTarget: mounted });
|
|
41
|
+
expect(mounted.showModal).toHaveBeenCalledOnce();
|
|
42
|
+
|
|
43
|
+
const gone = setup(false, false);
|
|
44
|
+
gone.handlers.onclose({ currentTarget: gone });
|
|
45
|
+
expect(gone.showModal).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// showModal() puts the dialog in the top layer and makes the page behind it
|
|
5
|
+
// inert. The dialog stays open while mounted; the caller's `open` flag drives
|
|
6
|
+
// (un)mounting.
|
|
7
|
+
export function showModal(node: HTMLDialogElement) {
|
|
8
|
+
node.showModal();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ModalDialogOptions {
|
|
12
|
+
closable: () => boolean;
|
|
13
|
+
isOpen: () => boolean;
|
|
14
|
+
close: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The slice of a pointer/click event the handlers read.
|
|
18
|
+
interface Pressed {
|
|
19
|
+
target: EventTarget | null;
|
|
20
|
+
currentTarget: EventTarget | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Event handlers to spread on a `<dialog use:showModal>`: Escape and a press on
|
|
25
|
+
* the bare dialog (its backdrop area) close it when closable.
|
|
26
|
+
*/
|
|
27
|
+
export function modalDialogHandlers({ closable, isOpen, close }: ModalDialogOptions) {
|
|
28
|
+
// Only a press that starts and ends on the backdrop closes the dialog, so a
|
|
29
|
+
// text selection dragged out of the panel doesn't.
|
|
30
|
+
let pressedBackdrop = false;
|
|
31
|
+
return {
|
|
32
|
+
oncancel(e: { preventDefault(): void }) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
if (closable()) close();
|
|
35
|
+
},
|
|
36
|
+
// Chrome ignores preventDefault() on a repeated Escape and closes anyway;
|
|
37
|
+
// reopen so a non-closable dialog stays up while mounted.
|
|
38
|
+
onclose(e: { currentTarget: { showModal(): void } }) {
|
|
39
|
+
if (isOpen()) e.currentTarget.showModal();
|
|
40
|
+
},
|
|
41
|
+
onpointerdown(e: Pressed) {
|
|
42
|
+
pressedBackdrop = e.target === e.currentTarget;
|
|
43
|
+
},
|
|
44
|
+
onclick(e: Pressed) {
|
|
45
|
+
if (closable() && pressedBackdrop && e.target === e.currentTarget) close();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -11,22 +11,32 @@
|
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
title?: Snippet | string;
|
|
14
|
+
/** A control before the title that must not toggle the panel (an enable switch). */
|
|
15
|
+
leading?: Snippet;
|
|
14
16
|
/** Controls beside the header that must not toggle it (a switch, a delete button). */
|
|
15
17
|
actions?: Snippet;
|
|
16
18
|
children?: Snippet;
|
|
17
19
|
open?: boolean;
|
|
18
20
|
disabled?: boolean;
|
|
19
21
|
variant?: AccordionItemVariant;
|
|
22
|
+
/**
|
|
23
|
+
* `framed` only: border and background colours in place of the default
|
|
24
|
+
* `border-outline bg-surface-raised`, to tint the panel by state (e.g.
|
|
25
|
+
* `border-warning-soft-border bg-warning-soft/30` for a stale one).
|
|
26
|
+
*/
|
|
27
|
+
frameClass?: string;
|
|
20
28
|
class?: string;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
let {
|
|
24
32
|
title,
|
|
33
|
+
leading,
|
|
25
34
|
actions,
|
|
26
35
|
children,
|
|
27
36
|
open = $bindable(false),
|
|
28
37
|
disabled = false,
|
|
29
38
|
variant = "plain",
|
|
39
|
+
frameClass = "border-outline bg-surface-raised",
|
|
30
40
|
class: className = "",
|
|
31
41
|
}: Props = $props();
|
|
32
42
|
|
|
@@ -61,12 +71,13 @@
|
|
|
61
71
|
const framed = $derived(variant === "framed");
|
|
62
72
|
</script>
|
|
63
73
|
|
|
64
|
-
<div
|
|
65
|
-
class="group {framed
|
|
66
|
-
? 'rounded-[4px] border border-outline bg-surface-raised'
|
|
67
|
-
: ''} {className}"
|
|
68
|
-
>
|
|
74
|
+
<div class="group {framed ? `rounded-[4px] border ${frameClass}` : ''} {className}">
|
|
69
75
|
<div class="flex items-center gap-3 {framed ? 'px-4' : ''}">
|
|
76
|
+
{#if leading}
|
|
77
|
+
<div class="flex shrink-0 items-center">
|
|
78
|
+
{@render leading()}
|
|
79
|
+
</div>
|
|
80
|
+
{/if}
|
|
70
81
|
<button
|
|
71
82
|
type="button"
|
|
72
83
|
onclick={toggle}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
|
+
import Spinner from "./Spinner.svelte";
|
|
6
7
|
import Tooltip from "./Tooltip.svelte";
|
|
7
8
|
|
|
8
9
|
type BadgeVariant = "default" | "primary" | "success" | "warning" | "danger" | "info";
|
|
@@ -92,11 +93,7 @@
|
|
|
92
93
|
info: "text-info",
|
|
93
94
|
};
|
|
94
95
|
|
|
95
|
-
const
|
|
96
|
-
sm: "h-3 w-3",
|
|
97
|
-
md: "h-3.5 w-3.5",
|
|
98
|
-
lg: "h-4 w-4",
|
|
99
|
-
};
|
|
96
|
+
const spinnerPx: Record<BadgeSize, number> = { sm: 12, md: 14, lg: 16 };
|
|
100
97
|
</script>
|
|
101
98
|
|
|
102
99
|
{#snippet chip(extraClass: string)}
|
|
@@ -107,25 +104,7 @@
|
|
|
107
104
|
>
|
|
108
105
|
{#if dot}
|
|
109
106
|
{#if loading}
|
|
110
|
-
<
|
|
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>
|
|
107
|
+
<Spinner size={spinnerPx[size]} label="" class={indicatorTextColors[variant]} />
|
|
129
108
|
{:else}
|
|
130
109
|
<span class="h-1.5 w-1.5 rounded-full {dotColors[variant]}"></span>
|
|
131
110
|
{/if}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
|
7
|
+
import Spinner from "./Spinner.svelte";
|
|
7
8
|
import {
|
|
8
9
|
BUTTON_BASE,
|
|
9
10
|
BUTTON_SIZES,
|
|
@@ -44,15 +45,7 @@
|
|
|
44
45
|
{...restProps}
|
|
45
46
|
>
|
|
46
47
|
{#if loading}
|
|
47
|
-
<
|
|
48
|
-
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
|
49
|
-
></circle>
|
|
50
|
-
<path
|
|
51
|
-
class="opacity-75"
|
|
52
|
-
fill="currentColor"
|
|
53
|
-
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"
|
|
54
|
-
></path>
|
|
55
|
-
</svg>
|
|
48
|
+
<Spinner size="sm" label="" />
|
|
56
49
|
{:else if icon}
|
|
57
50
|
<span class="shrink-0 group-active:scale-95">
|
|
58
51
|
{@render icon()}
|
|
@@ -92,18 +92,19 @@
|
|
|
92
92
|
{/if}
|
|
93
93
|
</div>
|
|
94
94
|
{/if}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
: ''}">{#if prompt}<span
|
|
95
|
+
<!-- The copy button gets its own column so a long line scrolls under the
|
|
96
|
+
edge of the code, never under the button. -->
|
|
97
|
+
<div class="flex">
|
|
98
|
+
<pre class="min-w-0 flex-1 overflow-x-auto font-mono {sizeClasses[size]}">{#if prompt}<span
|
|
100
99
|
class="mr-[1ch] select-none {tone.prompt}"
|
|
101
100
|
aria-hidden="true">{prompt}</span
|
|
102
101
|
>{/if}<code bind:this={codeEl}
|
|
103
102
|
>{#if children}{@render children()}{:else}{code}{/if}</code
|
|
104
103
|
></pre>
|
|
105
104
|
{#if copyable && !hasHeader}
|
|
106
|
-
|
|
105
|
+
<div class="flex shrink-0 items-start pt-2 pr-2 pl-2">
|
|
106
|
+
{@render copyButton("")}
|
|
107
|
+
</div>
|
|
107
108
|
{/if}
|
|
108
109
|
</div>
|
|
109
110
|
</div>
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import { X } from "@lucide/svelte";
|
|
7
7
|
import Heading from "./Heading.svelte";
|
|
8
|
-
import { portal } from "../actions/portal.js";
|
|
9
8
|
import { trapFocus } from "../actions/focusTrap.js";
|
|
10
|
-
import {
|
|
9
|
+
import { modalDialogHandlers, showModal } from "../actions/modal-dialog.js";
|
|
10
|
+
import { fade, fly } from "svelte/transition";
|
|
11
11
|
|
|
12
12
|
type Side = "left" | "right" | "top" | "bottom";
|
|
13
13
|
type DrawerSize = "sm" | "md" | "lg" | "xl";
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
class: className = "",
|
|
39
39
|
}: Props = $props();
|
|
40
40
|
|
|
41
|
+
const titleId = $props.id();
|
|
42
|
+
|
|
41
43
|
const sizeValues: Record<DrawerSize, string> = {
|
|
42
44
|
sm: "320px",
|
|
43
45
|
md: "400px",
|
|
@@ -72,32 +74,25 @@
|
|
|
72
74
|
onClose?.();
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
77
|
+
const dialogHandlers = modalDialogHandlers({
|
|
78
|
+
closable: () => closable,
|
|
79
|
+
isOpen: () => open,
|
|
80
|
+
close: handleClose,
|
|
81
|
+
});
|
|
80
82
|
</script>
|
|
81
83
|
|
|
82
|
-
<svelte:window onkeydown={handleKeydown} />
|
|
83
|
-
|
|
84
84
|
{#if open}
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
<div
|
|
97
|
-
class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
|
|
98
|
-
transition:fly={{ duration: 150 }}
|
|
99
|
-
></div>
|
|
100
|
-
{/if}
|
|
85
|
+
<dialog
|
|
86
|
+
use:showModal
|
|
87
|
+
{...dialogHandlers}
|
|
88
|
+
aria-labelledby={title ? titleId : undefined}
|
|
89
|
+
class="fixed inset-0 m-0 h-full max-h-none w-full max-w-none border-0 bg-transparent p-0 text-inherit backdrop:bg-transparent"
|
|
90
|
+
>
|
|
91
|
+
<div
|
|
92
|
+
class="pointer-events-none absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
transition:fade={{ duration: 150 }}
|
|
95
|
+
></div>
|
|
101
96
|
|
|
102
97
|
<div
|
|
103
98
|
use:trapFocus
|
|
@@ -108,9 +103,6 @@
|
|
|
108
103
|
{className}
|
|
109
104
|
"
|
|
110
105
|
style={panelStyle}
|
|
111
|
-
role="dialog"
|
|
112
|
-
aria-modal="true"
|
|
113
|
-
aria-labelledby={title ? "drawer-title" : undefined}
|
|
114
106
|
transition:fly={{ ...flyParams[side], duration: 250 }}
|
|
115
107
|
>
|
|
116
108
|
{#if header}
|
|
@@ -123,7 +115,7 @@
|
|
|
123
115
|
>
|
|
124
116
|
<div>
|
|
125
117
|
{#if title}
|
|
126
|
-
<Heading level={2} size="lg" id=
|
|
118
|
+
<Heading level={2} size="lg" id={titleId}>{title}</Heading>
|
|
127
119
|
{/if}
|
|
128
120
|
</div>
|
|
129
121
|
{#if closable}
|
|
@@ -150,5 +142,5 @@
|
|
|
150
142
|
</div>
|
|
151
143
|
{/if}
|
|
152
144
|
</div>
|
|
153
|
-
</
|
|
145
|
+
</dialog>
|
|
154
146
|
{/if}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import { CircleAlert, Clock } from "@lucide/svelte";
|
|
6
|
-
import
|
|
6
|
+
import { formatDurationExact, parseDuration } from "../utils/format.js";
|
|
7
7
|
|
|
8
8
|
type InputSize = "sm" | "md" | "lg";
|
|
9
9
|
|
|
@@ -56,28 +56,9 @@
|
|
|
56
56
|
const errorBorder =
|
|
57
57
|
"border-danger-surface focus:border-danger-surface focus:ring-danger-surface";
|
|
58
58
|
|
|
59
|
-
function formatMs(msValue: number): string {
|
|
60
|
-
if (typeof msValue !== "number" || isNaN(msValue)) return "";
|
|
61
|
-
try {
|
|
62
|
-
return ms(msValue);
|
|
63
|
-
} catch {
|
|
64
|
-
return "";
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function parseToMs(str: string): number | null {
|
|
69
|
-
if (!str || !str.trim()) return null;
|
|
70
|
-
try {
|
|
71
|
-
const result = ms(str as StringValue);
|
|
72
|
-
return typeof result === "number" ? result : null;
|
|
73
|
-
} catch {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
59
|
function updateFromValue() {
|
|
79
60
|
if (!isFocused) {
|
|
80
|
-
textValue = value != null ?
|
|
61
|
+
textValue = value != null ? formatDurationExact(value) : "";
|
|
81
62
|
}
|
|
82
63
|
}
|
|
83
64
|
|
|
@@ -89,11 +70,11 @@
|
|
|
89
70
|
const val = e.currentTarget.value;
|
|
90
71
|
textValue = val;
|
|
91
72
|
|
|
92
|
-
const msValue =
|
|
73
|
+
const msValue = parseDuration(val);
|
|
93
74
|
value = msValue;
|
|
94
75
|
|
|
95
|
-
if (
|
|
96
|
-
parsedHuman =
|
|
76
|
+
if (msValue !== null) {
|
|
77
|
+
parsedHuman = formatDurationExact(msValue);
|
|
97
78
|
if (error) error = undefined;
|
|
98
79
|
} else {
|
|
99
80
|
parsedHuman = "";
|
|
@@ -106,7 +87,7 @@
|
|
|
106
87
|
|
|
107
88
|
function handleBlur() {
|
|
108
89
|
isFocused = false;
|
|
109
|
-
textValue = value != null ?
|
|
90
|
+
textValue = value != null ? formatDurationExact(value) : "";
|
|
110
91
|
parsedHuman = "";
|
|
111
92
|
}
|
|
112
93
|
</script>
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import SearchInput from "./SearchInput.svelte";
|
|
21
21
|
import FilterPill from "./FilterPill.svelte";
|
|
22
22
|
import Popover from "./Popover.svelte";
|
|
23
|
+
import { isTypingTarget } from "../utils/typing-target.js";
|
|
23
24
|
import { Plus, RotateCcw } from "@lucide/svelte";
|
|
24
25
|
import {
|
|
25
26
|
type FilterField,
|
|
@@ -83,12 +84,7 @@
|
|
|
83
84
|
// `/` focuses the search box, unless the user is already typing somewhere.
|
|
84
85
|
function onWindowKeydown(e: KeyboardEvent) {
|
|
85
86
|
if (e.key !== "/" || !searchEl) return;
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
t instanceof HTMLElement &&
|
|
89
|
-
(t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
|
|
90
|
-
)
|
|
91
|
-
return;
|
|
87
|
+
if (isTypingTarget(e.target)) return;
|
|
92
88
|
e.preventDefault();
|
|
93
89
|
searchEl.focus();
|
|
94
90
|
}
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
|
7
7
|
|
|
8
|
-
type LinkVariant = "default" | "muted" | "primary" | "danger";
|
|
8
|
+
type LinkVariant = "default" | "muted" | "primary" | "danger" | "unstyled";
|
|
9
9
|
type LinkUnderline = "always" | "hover" | "none";
|
|
10
10
|
|
|
11
11
|
// Use Link for off-site URLs (`external` opens a new tab with rel=noopener
|
|
12
|
-
// noreferrer).
|
|
13
|
-
//
|
|
12
|
+
// noreferrer). `unstyled` adds no classes beyond `class` (no `underline`
|
|
13
|
+
// either), for nav rows and menu items that bring their own layout. An
|
|
14
|
+
// in-app href must still come from SvelteKit's resolve(): being a
|
|
15
|
+
// component, Link sits outside svelte/no-navigation-without-resolve.
|
|
14
16
|
interface Props extends Omit<HTMLAnchorAttributes, "href" | "children" | "class"> {
|
|
15
17
|
href: string;
|
|
16
18
|
variant?: LinkVariant;
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
muted: "text-on-surface-muted hover:text-on-surface",
|
|
42
44
|
primary: "text-primary hover:text-primary-hover",
|
|
43
45
|
danger: "text-danger-surface hover:text-danger-hover",
|
|
46
|
+
unstyled: "",
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
const underlineClasses: Record<LinkUnderline, string> = {
|
|
@@ -52,7 +55,9 @@
|
|
|
52
55
|
|
|
53
56
|
<a
|
|
54
57
|
{href}
|
|
55
|
-
class=
|
|
58
|
+
class={variant === "unstyled"
|
|
59
|
+
? className
|
|
60
|
+
: `${baseClasses} ${variantClasses[variant]} ${underlineClasses[underline]} ${className}`}
|
|
56
61
|
target={external ? "_blank" : undefined}
|
|
57
62
|
rel={external ? "noopener noreferrer" : undefined}
|
|
58
63
|
{...restProps}
|