@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.
Files changed (70) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/focusTrap.ts +3 -3
  3. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  4. package/src/lib/actions/fold-to-fit.ts +89 -0
  5. package/src/lib/actions/modal-dialog.test.ts +47 -0
  6. package/src/lib/actions/modal-dialog.ts +48 -0
  7. package/src/lib/components/AccordionItem.svelte +16 -5
  8. package/src/lib/components/Badge.svelte +4 -25
  9. package/src/lib/components/Button.svelte +2 -9
  10. package/src/lib/components/CodeBlock.svelte +7 -6
  11. package/src/lib/components/DataGrid.svelte +5 -5
  12. package/src/lib/components/Drawer.svelte +22 -30
  13. package/src/lib/components/DurationInput.svelte +6 -25
  14. package/src/lib/components/FilterBar.svelte +3 -7
  15. package/src/lib/components/FilterPill.svelte +2 -2
  16. package/src/lib/components/FormField.svelte +1 -1
  17. package/src/lib/components/Link.svelte +9 -4
  18. package/src/lib/components/LogConsole.svelte +101 -424
  19. package/src/lib/components/Modal.svelte +7 -30
  20. package/src/lib/components/SearchInput.svelte +1 -1
  21. package/src/lib/components/Select.svelte +3 -2
  22. package/src/lib/components/Sparkline.svelte +2 -2
  23. package/src/lib/components/Spinner.svelte +10 -5
  24. package/src/lib/components/StatusDot.svelte +1 -1
  25. package/src/lib/components/StatusIndicator.svelte +37 -104
  26. package/src/lib/components/Tabs.svelte +15 -1
  27. package/src/lib/components/Tooltip.svelte +1 -1
  28. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  29. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  30. package/src/lib/components/dashboard/RunDetailPanel.svelte +137 -725
  31. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  32. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  33. package/src/lib/components/dashboard/RunFilterPopover.svelte +9 -10
  34. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  35. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  36. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  37. package/src/lib/components/dashboard/RunsList.svelte +267 -545
  38. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  39. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  40. package/src/lib/components/dashboard/cron-format.test.ts +10 -1
  41. package/src/lib/components/dashboard/cron-format.ts +7 -3
  42. package/src/lib/components/dashboard/run-filters.test.ts +62 -2
  43. package/src/lib/components/dashboard/run-filters.ts +104 -99
  44. package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
  45. package/src/lib/components/dashboard/run-helpers.ts +150 -44
  46. package/src/lib/components/dashboard/status-config.ts +18 -0
  47. package/src/lib/components/dashboard/types.ts +6 -0
  48. package/src/lib/components/data-grid-selection.ts +2 -9
  49. package/src/lib/components/filter-spec.ts +3 -3
  50. package/src/lib/index.ts +13 -2
  51. package/src/lib/log-console/AnsiLine.svelte +12 -0
  52. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  53. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  54. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  55. package/src/lib/log-console/ansi.ts +1 -1
  56. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  57. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  58. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  59. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  60. package/src/lib/theme-tokens.css +21 -21
  61. package/src/lib/theme.css +13 -13
  62. package/src/lib/utils/format.test.ts +125 -3
  63. package/src/lib/utils/format.ts +101 -8
  64. package/src/lib/utils/toast.svelte.ts +2 -3
  65. package/src/lib/utils/typing-target.ts +10 -0
  66. package/src/stories/Accordion.stories.svelte +60 -1
  67. package/src/stories/CodeBlock.stories.svelte +13 -0
  68. package/src/stories/Link.stories.svelte +25 -1
  69. package/src/stories/LogConsole.stories.svelte +2 -2
  70. 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.5.0",
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.5.0",
53
- "@runwisp/typescript-config": "1.5.0",
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.5.0",
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 — 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.
@@ -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 — pick one that has room, e.g. "bottom" near the top of the viewport. */
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 spinnerSizeClasses: Record<BadgeSize, string> = {
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
- <svg
111
- class="{spinnerSizeClasses[size]} animate-spin {indicatorTextColors[variant]}"
112
- viewBox="0 0 24 24"
113
- fill="none"
114
- >
115
- <circle
116
- class="opacity-25"
117
- cx="12"
118
- cy="12"
119
- r="10"
120
- stroke="currentColor"
121
- stroke-width="4"
122
- ></circle>
123
- <path
124
- class="opacity-75"
125
- fill="currentColor"
126
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
127
- ></path>
128
- </svg>
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
- <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
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
- <div class="relative">
96
- <pre
97
- class="overflow-x-auto font-mono {sizeClasses[size]} {copyable && !hasHeader
98
- ? 'pr-24'
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
- {@render copyButton("absolute top-2 right-2")}
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 — for single-column feeds (e.g. an
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 — e.g. a cursor
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 — e.g. a left accent bar for a failing 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 — sort stays inert there.
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 — airy by default, tight when compact. Header + body share it.
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 { fly } from "svelte/transition";
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
- function handleKeydown(e: KeyboardEvent) {
76
- if (e.key === "Escape" && closable) {
77
- handleClose();
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
- <div use:portal class="fixed inset-0 z-50">
86
- {#if closable}
87
- <button
88
- type="button"
89
- class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
90
- aria-label="Close drawer"
91
- tabindex="-1"
92
- onclick={handleClose}
93
- transition:fly={{ duration: 150 }}
94
- ></button>
95
- {:else}
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="drawer-title">{title}</Heading>
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
- </div>
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 ms, { type StringValue } from "ms";
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 ? formatMs(value) : "";
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 = parseToMs(val);
73
+ const msValue = parseDuration(val);
93
74
  value = msValue;
94
75
 
95
- if (typeof msValue === "number") {
96
- parsedHuman = ms(msValue, { long: true });
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 ? formatMs(value) : "";
90
+ textValue = value != null ? formatDurationExact(value) : "";
110
91
  parsedHuman = "";
111
92
  }
112
93
  </script>