@runwisp/ui 1.5.0 → 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/focusTrap.ts +3 -3
- 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 +4 -25
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/DataGrid.svelte +5 -5
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +3 -7
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +101 -424
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +3 -2
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/Tooltip.svelte +1 -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 +137 -725
- 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 +9 -10
- 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 +267 -545
- 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 +10 -1
- package/src/lib/components/dashboard/cron-format.ts +7 -3
- package/src/lib/components/dashboard/run-filters.test.ts +62 -2
- package/src/lib/components/dashboard/run-filters.ts +104 -99
- package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
- package/src/lib/components/dashboard/run-helpers.ts +150 -44
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- 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/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/ansi.ts +1 -1
- 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/theme-tokens.css +21 -21
- package/src/lib/theme.css +13 -13
- package/src/lib/utils/format.test.ts +125 -3
- package/src/lib/utils/format.ts +101 -8
- package/src/lib/utils/toast.svelte.ts +2 -3
- 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/LogConsole.stories.svelte +2 -2
- 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
|
}
|
|
@@ -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.
|
|
@@ -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";
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
outline?: boolean;
|
|
18
19
|
/** Shown in the styled hover tooltip (see {@link Tooltip}). */
|
|
19
20
|
tooltip?: string;
|
|
20
|
-
/** Side the tooltip opens on
|
|
21
|
+
/** Side the tooltip opens on, pick one that has room, e.g. "bottom" near the top of the viewport. */
|
|
21
22
|
tooltipPosition?: TooltipPosition;
|
|
22
23
|
children?: Snippet;
|
|
23
24
|
class?: string;
|
|
@@ -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>
|
|
@@ -46,16 +46,16 @@
|
|
|
46
46
|
onRowClick?: (row: T) => void;
|
|
47
47
|
loading?: boolean;
|
|
48
48
|
stickyHeader?: boolean;
|
|
49
|
-
/** Hide the header row entirely
|
|
49
|
+
/** Hide the header row entirely, for single-column feeds (e.g. an
|
|
50
50
|
* inbox) where a column label would be noise. */
|
|
51
51
|
showHeader?: boolean;
|
|
52
|
-
/** Rendered below the table body, inside the frame
|
|
52
|
+
/** Rendered below the table body, inside the frame, e.g. a cursor
|
|
53
53
|
* "Load more" button for feeds that don't use offset pagination. */
|
|
54
54
|
footer?: Snippet;
|
|
55
55
|
/** Drop the outer border/shadow/bg so the grid sits flush inside a Card
|
|
56
56
|
* or panel that already supplies its own frame. */
|
|
57
57
|
bare?: boolean;
|
|
58
|
-
/** Extra classes per row
|
|
58
|
+
/** Extra classes per row, e.g. a left accent bar for a failing row.
|
|
59
59
|
* Rows always carry `group`, so a `render`/`rowAction` snippet can use
|
|
60
60
|
* `group-hover:` to reveal on-hover controls. */
|
|
61
61
|
rowClass?: (row: T) => string;
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
// Type-aware comparator. Nullish sorts last; numbers numerically; dates by
|
|
67
67
|
// epoch; everything else by locale with numeric-aware string compare.
|
|
68
68
|
// Returns 0 for non-comparable/equal, so a column whose key maps to no real
|
|
69
|
-
// field (a display-only key) leaves order untouched
|
|
69
|
+
// field (a display-only key) leaves order untouched, sort stays inert there.
|
|
70
70
|
function compareValues(a: unknown, b: unknown): number {
|
|
71
71
|
if (a == null && b == null) return 0;
|
|
72
72
|
if (a == null) return 1;
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
let allSelected = $derived(selection.allSelected);
|
|
194
194
|
let someSelected = $derived(selection.someSelected);
|
|
195
195
|
|
|
196
|
-
// Cell rhythm
|
|
196
|
+
// Cell rhythm, airy by default, tight when compact. Header + body share it.
|
|
197
197
|
const density = $derived(compact ? "compact" : "comfortable");
|
|
198
198
|
// Hover reveals a teal accent rail on the leftmost cell (see .group on <tr>).
|
|
199
199
|
const railHover = "group-hover:shadow-[inset_3px_0_0_var(--color-primary)]";
|
|
@@ -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>
|