@runwisp/ui 1.5.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogCache.svelte.test.ts +135 -0
  43. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  44. package/src/lib/log-console/LogFetcher.svelte.test.ts +159 -0
  45. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  46. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  47. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  48. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  49. package/src/lib/utils/format.test.ts +122 -0
  50. package/src/lib/utils/format.ts +97 -4
  51. package/src/lib/utils/toast.svelte.test.ts +46 -0
  52. package/src/lib/utils/typing-target.ts +10 -0
  53. package/src/stories/Accordion.stories.svelte +60 -1
  54. package/src/stories/CodeBlock.stories.svelte +13 -0
  55. package/src/stories/Link.stories.svelte +25 -1
  56. 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.1",
4
+ "version": "1.7.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.5.1",
53
- "@runwisp/typescript-config": "1.5.1",
52
+ "@runwisp/eslint-config": "1.7.1",
53
+ "@runwisp/typescript-config": "1.7.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",
@@ -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.1",
95
+ "@runwisp/common": "1.7.1",
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 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>
@@ -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>
@@ -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
- const t = e.target;
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). An in-app href must still come from SvelteKit's resolve():
13
- // being a component, Link sits outside svelte/no-navigation-without-resolve.
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="{baseClasses} {variantClasses[variant]} {underlineClasses[underline]} {className}"
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}