@runwisp/ui 1.3.0 → 1.5.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 (73) hide show
  1. package/package.json +5 -6
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/portal.ts +5 -1
  4. package/src/lib/components/Accordion.svelte +6 -2
  5. package/src/lib/components/AccordionItem.svelte +80 -23
  6. package/src/lib/components/Avatar.svelte +0 -1
  7. package/src/lib/components/CodeBlock.svelte +84 -35
  8. package/src/lib/components/CronInput.svelte +5 -2
  9. package/src/lib/components/DataGrid.svelte +95 -133
  10. package/src/lib/components/Drawer.svelte +2 -6
  11. package/src/lib/components/Dropdown.svelte +19 -57
  12. package/src/lib/components/EmptyState.svelte +2 -1
  13. package/src/lib/components/ErrorState.svelte +2 -1
  14. package/src/lib/components/Heading.svelte +19 -12
  15. package/src/lib/components/Kbd.svelte +25 -10
  16. package/src/lib/components/Link.svelte +7 -1
  17. package/src/lib/components/LogConsole.svelte +37 -34
  18. package/src/lib/components/Modal.svelte +44 -33
  19. package/src/lib/components/NotificationBell.svelte +2 -1
  20. package/src/lib/components/PageContainer.svelte +1 -10
  21. package/src/lib/components/PageHeader.svelte +8 -4
  22. package/src/lib/components/Popover.svelte +9 -51
  23. package/src/lib/components/Progress.svelte +19 -6
  24. package/src/lib/components/Radio.svelte +16 -4
  25. package/src/lib/components/RadioGroup.svelte +15 -2
  26. package/src/lib/components/Select.svelte +13 -45
  27. package/src/lib/components/Skeleton.svelte +6 -6
  28. package/src/lib/components/Table.svelte +13 -4
  29. package/src/lib/components/TableBody.svelte +2 -1
  30. package/src/lib/components/TableCell.svelte +38 -8
  31. package/src/lib/components/TableHead.svelte +12 -4
  32. package/src/lib/components/TableRow.svelte +23 -3
  33. package/src/lib/components/TaskCard.svelte +1 -1
  34. package/src/lib/components/TimezoneSelect.svelte +2 -2
  35. package/src/lib/components/accordion.svelte.ts +19 -0
  36. package/src/lib/components/dashboard/RunDetailPanel.svelte +141 -67
  37. package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
  38. package/src/lib/components/dashboard/RunsList.svelte +57 -37
  39. package/src/lib/components/dashboard/cron-format.ts +1 -1
  40. package/src/lib/components/dashboard/run-filters.test.ts +2 -58
  41. package/src/lib/components/dashboard/run-filters.ts +5 -53
  42. package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
  43. package/src/lib/components/dashboard/run-helpers.ts +12 -1
  44. package/src/lib/components/dashboard/status-config.ts +38 -83
  45. package/src/lib/components/dashboard/types.ts +0 -26
  46. package/src/lib/components/radio-group.ts +11 -0
  47. package/src/lib/index.ts +8 -43
  48. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  49. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  50. package/src/lib/log-console/highlight-scroll.ts +41 -0
  51. package/src/lib/theme.css +10 -4
  52. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  53. package/src/lib/utils/clipboard.svelte.ts +73 -0
  54. package/src/lib/utils/format.ts +65 -94
  55. package/src/lib/utils/id.test.ts +3 -7
  56. package/src/lib/utils/id.ts +3 -2
  57. package/src/lib/utils/theme.svelte.test.ts +0 -6
  58. package/src/lib/utils/theme.svelte.ts +3 -5
  59. package/src/lib/utils/toast.svelte.ts +3 -11
  60. package/src/stories/Accordion.stories.svelte +61 -0
  61. package/src/stories/Button.stories.svelte +2 -2
  62. package/src/stories/CodeBlock.stories.svelte +55 -0
  63. package/src/stories/DataGrid.stories.svelte +2 -7
  64. package/src/stories/Dropdown.stories.svelte +3 -3
  65. package/src/stories/Heading.stories.svelte +31 -0
  66. package/src/stories/Kbd.stories.svelte +36 -0
  67. package/src/stories/Link.stories.svelte +38 -0
  68. package/src/stories/PageHeader.stories.svelte +31 -0
  69. package/src/stories/Progress.stories.svelte +26 -0
  70. package/src/stories/RadioGroup.stories.svelte +43 -0
  71. package/src/stories/Table.stories.svelte +69 -0
  72. package/src/svelte-shims.d.ts +0 -5
  73. package/src/lib/components/dashboard/status-config.test.ts +0 -18
@@ -1,32 +1,6 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { Task as CommonTask, Run as CommonRun } from "@runwisp/common";
5
-
6
- /**
7
- * Web UI component types.
8
- * Source of truth for status values: {@link RunStatus} from `@runwisp/common`.
9
- */
10
-
11
- export type Task = CommonTask;
12
-
13
- export type Run = CommonRun;
14
-
15
- export interface DaemonState {
16
- name: string;
17
- version: string;
18
- uptime: string;
19
- status: "connected" | "disconnected";
20
- host: string;
21
- backendUrl: string;
22
- cpus: number;
23
- memory: string;
24
- os: string;
25
- arch: string;
26
- workDir: string;
27
- fingerprint: string;
28
- }
29
-
30
4
  export interface DaemonStats {
31
5
  cpuUsage: number;
32
6
  memUsage: number;
@@ -0,0 +1,11 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export const RADIO_GROUP_KEY = Symbol("radio-group");
5
+
6
+ // What a RadioGroup hands its Radio children: the shared input name and the
7
+ // selected value, read and written through the group's bindable `value`.
8
+ export interface RadioGroupContext {
9
+ readonly name: string;
10
+ value: string;
11
+ }
package/src/lib/index.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- export { default as theme } from "./theme.css?inline";
5
-
6
4
  export { default as Logo } from "./components/Logo.svelte";
7
5
 
8
6
  export { default as PageContainer } from "./components/PageContainer.svelte";
@@ -19,13 +17,6 @@ export { default as Spinner } from "./components/Spinner.svelte";
19
17
 
20
18
  export { default as Button } from "./components/Button.svelte";
21
19
  export { default as LinkButton } from "./components/LinkButton.svelte";
22
- export {
23
- BUTTON_BASE,
24
- BUTTON_SIZES,
25
- BUTTON_VARIANTS,
26
- type ButtonSize,
27
- type ButtonVariant,
28
- } from "./components/button-styles.js";
29
20
  export { default as Checkbox } from "./components/Checkbox.svelte";
30
21
  export { default as CronInput } from "./components/CronInput.svelte";
31
22
  export { default as DurationInput } from "./components/DurationInput.svelte";
@@ -33,7 +24,7 @@ export { default as FormField } from "./components/FormField.svelte";
33
24
  export { default as Input } from "./components/Input.svelte";
34
25
  export { default as Radio } from "./components/Radio.svelte";
35
26
  export { default as RadioGroup } from "./components/RadioGroup.svelte";
36
- export { default as Select, type SelectOption } from "./components/Select.svelte";
27
+ export { default as Select } from "./components/Select.svelte";
37
28
  export { default as Textarea } from "./components/Textarea.svelte";
38
29
  export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
39
30
  export { default as Toggle } from "./components/Toggle.svelte";
@@ -88,71 +79,45 @@ export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
88
79
 
89
80
  export { default as CodeBlock } from "./components/CodeBlock.svelte";
90
81
  export { default as TaskCard } from "./components/TaskCard.svelte";
91
- export type { TaskCardAccent } from "./components/TaskCard.svelte";
92
82
 
93
83
  export { default as LogConsole } from "./components/LogConsole.svelte";
94
84
  export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
95
- export { isLogEvent } from "./log-console/types.js";
96
- export { LogCache } from "./log-console/LogCache.svelte.js";
97
- export { LogFetcher } from "./log-console/LogFetcher.svelte.js";
98
85
 
99
86
  export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
100
87
  export { default as RunsList } from "./components/dashboard/RunsList.svelte";
101
88
  export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
102
89
  export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
103
90
  export {
91
+ activeFilterCount,
104
92
  emptyRunFilters,
105
93
  FAILURE_STATUS_TOKEN,
106
- NEEDS_ATTENTION_STATUSES,
107
- isNeedsAttention,
108
- activeFilterCount,
109
- activeDimensions,
110
- dimensionActive,
111
- clearDimension,
112
- clearPopoverFilters,
113
94
  humanizeStatus,
114
- statusChipLabel,
115
95
  exitCodeRange,
116
96
  type ExitCodeRange,
117
97
  type RunsListFilters,
118
- type RunsListSortDirection,
119
- type FilterDimension,
120
98
  } from "./components/dashboard/run-filters.js";
121
- export {
122
- getRunStatusConfig,
123
- runDisplayStatus,
124
- RUN_STATUS_CONFIG,
125
- type RunStatusConfig,
126
- } from "./components/dashboard/status-config.js";
99
+ export { RUN_STATUS_CONFIG } from "./components/dashboard/status-config.js";
127
100
  export {
128
101
  runDuration,
129
- runStartDelay,
130
102
  runPhaseOrder,
131
103
  formatTriggeredByLabel,
132
- runRetryLabel,
133
104
  instanceSuffix,
134
105
  } from "./components/dashboard/run-helpers.js";
135
- export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
136
- export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
106
+ export { humanizeCron } from "./components/dashboard/cron-format.js";
107
+ export type { DaemonStats } from "./components/dashboard/types.js";
137
108
 
138
- export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
139
- export {
140
- themeStore,
141
- THEME_STORAGE_KEY,
142
- type ThemePreference,
143
- type ResolvedTheme,
144
- } from "./utils/theme.svelte.js";
109
+ export { toast } from "./utils/toast.svelte.js";
145
110
  export { extractErrorMessage } from "./utils/error.js";
146
111
  export {
147
112
  formatBytes,
148
113
  formatRelativeTime,
149
114
  formatRelativeTimeWithAbsolute,
150
- formatDateTime,
151
115
  formatDuration,
152
116
  formatFullDateTime,
153
117
  } from "./utils/format.js";
154
118
  export { formatShortId } from "./utils/id.js";
155
119
  export { debounce } from "./utils/debounce.js";
156
120
  export { TickingNow } from "./utils/ticking-now.svelte.js";
121
+ export { CopyFeedback, copyText } from "./utils/clipboard.svelte.js";
157
122
  export { RunMotion } from "./utils/run-motion.js";
158
- export { arrival, leave, shift, prefersReducedMotion } from "./actions/row-motion.js";
123
+ export { arrival, leave, shift } from "./actions/row-motion.js";
@@ -1,13 +1,12 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { createLoggerFactory } from "@runwisp/common";
4
+ import { createLogger } from "@runwisp/common";
5
5
  import { SvelteSet } from "svelte/reactivity";
6
6
  import type { LogCache } from "./LogCache.svelte.js";
7
7
  import type { FetchLogsFn } from "./types.js";
8
8
  import { isLogEvent } from "./types.js";
9
9
 
10
- const createLogger = createLoggerFactory();
11
10
  const logger = createLogger("LogFetcher");
12
11
 
13
12
  /**
@@ -20,8 +19,8 @@ const logger = createLogger("LogFetcher");
20
19
  */
21
20
  export class LogFetcher {
22
21
  private readonly cache: LogCache;
23
- private fetchLogsFn: FetchLogsFn | undefined;
24
- private chunkSize: number;
22
+ private readonly fetchLogsFn: FetchLogsFn | undefined;
23
+ private readonly chunkSize = 4096;
25
24
 
26
25
  readonly MAX_INFLIGHT = 2;
27
26
  readonly MIN_REQUEST_INTERVAL_MS = 140;
@@ -38,23 +37,13 @@ export class LogFetcher {
38
37
  constructor(
39
38
  cache: LogCache,
40
39
  fetchLogsFn: FetchLogsFn | undefined,
41
- chunkSize: number = 4096,
42
40
  onDataLoaded?: (min: number, max: number) => void,
43
41
  ) {
44
42
  this.cache = cache;
45
43
  this.fetchLogsFn = fetchLogsFn;
46
- this.chunkSize = Math.max(1, Math.floor(chunkSize));
47
44
  this.onDataLoaded = onDataLoaded;
48
45
  }
49
46
 
50
- setFetchLogsFn(fn: FetchLogsFn | undefined) {
51
- this.fetchLogsFn = fn;
52
- }
53
-
54
- setChunkSize(size: number) {
55
- this.chunkSize = Math.max(1, Math.floor(size));
56
- }
57
-
58
47
  enqueue(from: number, to: number) {
59
48
  if (!this.fetchLogsFn) return;
60
49
 
@@ -0,0 +1,59 @@
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 { createHighlightScroll } from "./highlight-scroll.js";
6
+
7
+ // `sync` is what LogConsole calls inside its $effect. The effect re-runs it
8
+ // whenever something it READ changes, so "no layout reads after the reveal"
9
+ // (ready not consulted again) is exactly "layout changes can't re-scroll".
10
+ function setup(initial: { line: number | null; ready: boolean }) {
11
+ const state = { ...initial };
12
+ const ready = vi.fn(() => state.ready);
13
+ const reveal = vi.fn();
14
+ const clear = vi.fn();
15
+ const sync = createHighlightScroll({ line: () => state.line, ready, reveal, clear });
16
+ return { state, ready, reveal, clear, sync };
17
+ }
18
+
19
+ describe("createHighlightScroll", () => {
20
+ it("reveals once and stops reading layout afterwards", () => {
21
+ const { ready, reveal, sync } = setup({ line: 5, ready: true });
22
+ sync();
23
+ expect(reveal).toHaveBeenCalledExactlyOnceWith(5);
24
+ expect(ready).toHaveBeenCalledTimes(1);
25
+
26
+ // A running run appends lines or the pane resizes: the effect re-runs,
27
+ // but must neither scroll again nor re-read the layout.
28
+ sync();
29
+ sync();
30
+ expect(reveal).toHaveBeenCalledTimes(1);
31
+ expect(ready).toHaveBeenCalledTimes(1);
32
+ });
33
+
34
+ it("waits until the line can be placed, then reveals once", () => {
35
+ const { state, reveal, sync } = setup({ line: 5, ready: false });
36
+ sync();
37
+ expect(reveal).not.toHaveBeenCalled();
38
+ state.ready = true;
39
+ sync();
40
+ sync();
41
+ expect(reveal).toHaveBeenCalledExactlyOnceWith(5);
42
+ });
43
+
44
+ it("reveals again for a new line and after the highlight is cleared", () => {
45
+ const { state, reveal, clear, sync } = setup({ line: 5, ready: true });
46
+ sync();
47
+ state.line = 9;
48
+ sync();
49
+ expect(reveal).toHaveBeenLastCalledWith(9);
50
+
51
+ state.line = null;
52
+ sync();
53
+ expect(clear).toHaveBeenCalledOnce();
54
+
55
+ state.line = 9;
56
+ sync();
57
+ expect(reveal).toHaveBeenCalledTimes(3);
58
+ });
59
+ });
@@ -0,0 +1,41 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { untrack } from "svelte";
5
+
6
+ export interface HighlightScrollOptions {
7
+ /** Reactive getter for the line to reveal; null/undefined means none. */
8
+ line: () => number | null | undefined;
9
+ /** Reactive getter: true once the layout can place `line` (container measured, line exists). */
10
+ ready: (line: number) => boolean;
11
+ /** Scrolls to `line`. Runs untracked, so layout reads here never re-trigger the sync. */
12
+ reveal: (line: number) => void;
13
+ /** Called when the highlight is cleared. */
14
+ clear: () => void;
15
+ }
16
+
17
+ /**
18
+ * Returns a function to call from inside an `$effect`. It reveals each
19
+ * highlighted line exactly once: after the first reveal, layout changes (new
20
+ * log lines, resizes) no longer re-run the scroll, so the operator can scroll
21
+ * away from a deep-linked search hit and stay there. It waits until `ready`,
22
+ * and a changed or re-set `line` reveals again.
23
+ */
24
+ export function createHighlightScroll(options: HighlightScrollOptions): () => void {
25
+ let revealed: number | null = null;
26
+ return () => {
27
+ const target = options.line();
28
+ // Not `!target`: line 0 is a valid highlight.
29
+ if (typeof target !== "number") {
30
+ revealed = null;
31
+ options.clear();
32
+ return;
33
+ }
34
+ // Checked before `ready` so a revealed line leaves no layout dependency behind.
35
+ if (revealed === target || !options.ready(target)) return;
36
+ revealed = target;
37
+ untrack(() => {
38
+ options.reveal(target);
39
+ });
40
+ };
41
+ }
package/src/lib/theme.css CHANGED
@@ -19,6 +19,10 @@
19
19
  @plugin "@tailwindcss/typography";
20
20
 
21
21
  @theme {
22
+ /* Wide enough for the sidebar, run list and run detail to stay side by
23
+ * side for good; below it the operator can fold the first two away. */
24
+ --breakpoint-3xl: 100rem;
25
+
22
26
  /* Palette aliases — Tailwind utilities (bg-wisp-500, text-mist-900, …)
23
27
  * resolve through to --rw-color-* declared in theme-tokens.css. */
24
28
  --color-wisp-50: var(--rw-color-wisp-50);
@@ -251,10 +255,12 @@ body {
251
255
  color: var(--rw-on-surface);
252
256
  }
253
257
 
254
- /* Headings are mono chrome — the runbook voice. Components should not fight this. */
255
- h1,
256
- h2,
257
- h3 {
258
+ /* Headings are mono chrome — the runbook voice. Unlayered, so it beats the
259
+ weight/leading/tracking utilities on a raw <h1>-<h3>: every heading reads the
260
+ same. The Heading component opts out (data-rw-heading) and carries this look
261
+ in its own classes, so its props are the source of truth. :is()/:where()
262
+ keep the selector at element specificity, so app stylesheets still win. */
263
+ :is(h1, h2, h3):where(:not([data-rw-heading])) {
258
264
  font-family: var(--font-mono);
259
265
  font-weight: 800;
260
266
  line-height: 1.1;
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { CopyFeedback, copyText } from "./clipboard.svelte.js";
6
+
7
+ describe("copyText", () => {
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ });
11
+
12
+ it("writes through navigator.clipboard", async () => {
13
+ const writeText = vi.fn(() => Promise.resolve());
14
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
15
+ expect(await copyText("runwisp")).toBe(true);
16
+ expect(writeText).toHaveBeenCalledWith("runwisp");
17
+ });
18
+
19
+ it("falls back to execCommand when the clipboard API is missing", async () => {
20
+ vi.stubGlobal("navigator", {});
21
+ const textarea = {
22
+ value: "",
23
+ readOnly: false,
24
+ style: {},
25
+ select: vi.fn(),
26
+ remove: vi.fn(),
27
+ };
28
+ const execCommand = vi.fn(() => true);
29
+ class FakeElement {
30
+ focus = vi.fn();
31
+ }
32
+ const focused = new FakeElement();
33
+ vi.stubGlobal("HTMLElement", FakeElement);
34
+ vi.stubGlobal("document", {
35
+ activeElement: focused,
36
+ createElement: () => textarea,
37
+ body: { appendChild: vi.fn() },
38
+ execCommand,
39
+ });
40
+ expect(await copyText("runwisp")).toBe(true);
41
+ expect(textarea.value).toBe("runwisp");
42
+ expect(execCommand).toHaveBeenCalledWith("copy");
43
+ expect(textarea.remove).toHaveBeenCalled();
44
+ expect(focused.focus).toHaveBeenCalled();
45
+ });
46
+
47
+ it("reports failure when no copy path exists", async () => {
48
+ vi.stubGlobal("navigator", {});
49
+ vi.stubGlobal("document", undefined);
50
+ expect(await copyText("runwisp")).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe("CopyFeedback", () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ vi.stubGlobal("navigator", { clipboard: { writeText: () => Promise.resolve() } });
58
+ });
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ vi.unstubAllGlobals();
62
+ });
63
+
64
+ it("flags the copied key until the reset delay passes", async () => {
65
+ const feedback = new CopyFeedback(1000);
66
+ await feedback.copy("docker run", "docker");
67
+ expect(feedback.copied).toBe(true);
68
+ expect(feedback.isCopied("docker")).toBe(true);
69
+ expect(feedback.isCopied("binary")).toBe(false);
70
+ vi.advanceTimersByTime(1000);
71
+ expect(feedback.copied).toBe(false);
72
+ });
73
+
74
+ it("restarts the reset delay on a second copy", async () => {
75
+ const feedback = new CopyFeedback(1000);
76
+ await feedback.copy("a");
77
+ vi.advanceTimersByTime(800);
78
+ await feedback.copy("b");
79
+ vi.advanceTimersByTime(800);
80
+ expect(feedback.copied).toBe(true);
81
+ });
82
+
83
+ it("stays idle when the copy fails", async () => {
84
+ vi.stubGlobal("navigator", {
85
+ clipboard: { writeText: () => Promise.reject(new Error("denied")) },
86
+ });
87
+ vi.stubGlobal("document", undefined);
88
+ const feedback = new CopyFeedback();
89
+ expect(await feedback.copy("a")).toBe(false);
90
+ expect(feedback.copied).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const DEFAULT_RESET_MS = 2000;
5
+
6
+ // Copies text to the clipboard and reports whether it worked. navigator.clipboard
7
+ // only exists in secure contexts, so a daemon served over plain http on a LAN IP
8
+ // falls back to the legacy textarea + execCommand path.
9
+ export async function copyText(text: string): Promise<boolean> {
10
+ if (typeof navigator !== "undefined" && "clipboard" in navigator) {
11
+ try {
12
+ await navigator.clipboard.writeText(text);
13
+ return true;
14
+ } catch {
15
+ // Denied or blocked: try the legacy path below.
16
+ }
17
+ }
18
+ return legacyCopy(text);
19
+ }
20
+
21
+ function legacyCopy(text: string): boolean {
22
+ if (typeof document === "undefined") return false;
23
+ const previousFocus = document.activeElement;
24
+ const ta = document.createElement("textarea");
25
+ ta.value = text;
26
+ ta.readOnly = true;
27
+ ta.style.position = "fixed";
28
+ ta.style.opacity = "0";
29
+ document.body.appendChild(ta);
30
+ ta.select();
31
+ let ok = false;
32
+ try {
33
+ // Deprecated, but still the only copy path outside a secure context.
34
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
35
+ ok = document.execCommand("copy"); // NOSONAR
36
+ } catch {
37
+ ok = false;
38
+ }
39
+ ta.remove();
40
+ if (previousFocus instanceof HTMLElement) previousFocus.focus();
41
+ return ok;
42
+ }
43
+
44
+ // CopyFeedback drives a copy button's transient "copied" state. `key` tells
45
+ // several buttons sharing one instance apart (one per tab or snippet).
46
+ export class CopyFeedback {
47
+ #key = $state<string | null>(null);
48
+ #timer: ReturnType<typeof setTimeout> | undefined;
49
+ readonly #resetMs: number;
50
+
51
+ constructor(resetMs: number = DEFAULT_RESET_MS) {
52
+ this.#resetMs = resetMs;
53
+ }
54
+
55
+ get copied(): boolean {
56
+ return this.#key !== null;
57
+ }
58
+
59
+ isCopied(key: string): boolean {
60
+ return this.#key === key;
61
+ }
62
+
63
+ async copy(text: string, key: string = ""): Promise<boolean> {
64
+ const ok = await copyText(text);
65
+ if (!ok) return false;
66
+ clearTimeout(this.#timer);
67
+ this.#key = key;
68
+ this.#timer = setTimeout(() => {
69
+ this.#key = null;
70
+ }, this.#resetMs);
71
+ return true;
72
+ }
73
+ }