@heyramzi/ui 2.8.0 → 2.9.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 (115) hide show
  1. package/COMPONENTS.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +58 -74
  4. package/src/agents/vendor/draw.ts +4 -4
  5. package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
  6. package/src/canvas/ops/ops.ts +1 -1
  7. package/src/charts/area-chart.svelte +11 -1
  8. package/src/charts/bar-chart.svelte +18 -3
  9. package/src/charts/line-chart.svelte +11 -1
  10. package/src/components/data-table/data-table.ts +4 -5
  11. package/src/components/data-table/pagination.svelte +5 -3
  12. package/src/components/docs/on-this-page.svelte +82 -5
  13. package/src/components/docs/scroll-spy.svelte.ts +6 -1
  14. package/src/components/list-page/detail-header.svelte +7 -3
  15. package/src/components/list-page/empty-state.svelte +3 -1
  16. package/src/components/list-page/filter-popover.svelte +2 -2
  17. package/src/components/list-page/rail-row.svelte +5 -2
  18. package/src/components/mark/mark.svelte +2 -1
  19. package/src/components/section-card/section-card.svelte +3 -1
  20. package/src/components/section-header/section-header.svelte +5 -5
  21. package/src/components/segmented/segment.svelte +1 -1
  22. package/src/components/table-view/table-view.svelte +6 -1
  23. package/src/components/tags-input/tags-input.svelte +1 -1
  24. package/src/components/toolbar/toolbar.svelte +1 -1
  25. package/src/editor/anchors.ts +28 -0
  26. package/src/editor/doc-blocks.ts +168 -0
  27. package/src/editor/doc-page.css +389 -0
  28. package/src/editor/editor.svelte +47 -5
  29. package/src/editor/extensions.ts +26 -2
  30. package/src/editor/markdown.ts +27 -2
  31. package/src/editor/slash-items.ts +18 -0
  32. package/src/icons/trades.ts +1 -1
  33. package/src/inbox/README.md +6 -4
  34. package/src/inbox/inbox.test.ts +1 -1
  35. package/src/inbox/index.ts +2 -2
  36. package/src/inbox/logic.ts +29 -5
  37. package/src/inbox/thread-inbox.svelte +219 -92
  38. package/src/inbox/types.ts +27 -1
  39. package/src/lib/initials.test.ts +25 -0
  40. package/src/lib/initials.ts +29 -0
  41. package/src/modules.test.ts +4 -64
  42. package/src/panel/README.md +1 -1
  43. package/src/record/README.md +1 -1
  44. package/src/record/info-list.svelte +5 -4
  45. package/src/record/next-up.svelte +1 -1
  46. package/src/record/project-list.svelte +1 -1
  47. package/src/record/steps-rail.svelte +3 -3
  48. package/src/shadcn/badge/badge.svelte +1 -1
  49. package/src/shadcn/card/card-title.svelte +1 -5
  50. package/src/shadcn/empty/empty-description.svelte +1 -1
  51. package/src/shadcn/progress/progress.svelte +8 -1
  52. package/src/shadcn/table/table-cell.svelte +10 -2
  53. package/src/status/README.md +1 -1
  54. package/src/thread/README.md +2 -0
  55. package/src/thread/index.ts +9 -1
  56. package/src/thread/logic.ts +33 -3
  57. package/src/thread/thread-body.svelte +37 -0
  58. package/src/thread/thread-events.svelte +30 -0
  59. package/src/thread/thread.svelte +74 -59
  60. package/src/thread/types.ts +21 -0
  61. package/src/{tasks → work}/README.md +11 -8
  62. package/src/{tasks → work}/detail/task-thread.svelte +8 -15
  63. package/src/work/docs/doc-editor-page.svelte +194 -0
  64. package/src/work/docs/doc-menu.svelte +333 -0
  65. package/src/work/docs/docs-demo.svelte +227 -0
  66. package/src/work/docs/edited-chip.svelte +125 -0
  67. package/src/work/docs/edited.ts +54 -0
  68. package/src/work/docs/index.ts +38 -0
  69. package/src/work/docs/labels.ts +70 -0
  70. package/src/work/docs/menu.ts +127 -0
  71. package/src/work/docs/outline.ts +60 -0
  72. package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
  73. package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
  74. package/src/work/inbox/inbox-logic.ts +98 -0
  75. package/src/work/inbox/inbox-trace.svelte +58 -0
  76. package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
  77. package/src/{tasks → work}/index.ts +0 -10
  78. package/src/{tasks → work}/kanban-board.svelte +1 -1
  79. package/src/{tasks → work}/task-list.svelte +1 -1
  80. package/src/{tasks → work}/time/time-tracker.svelte +2 -5
  81. package/src/tasks/inbox/inbox-logic.ts +0 -21
  82. package/src/tasks/inbox/inbox-trace.svelte +0 -51
  83. /package/src/{tasks → work}/ambient.d.ts +0 -0
  84. /package/src/{tasks → work}/board-order.ts +0 -0
  85. /package/src/{tasks → work}/dates.ts +0 -0
  86. /package/src/{tasks → work}/demo.svelte +0 -0
  87. /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
  88. /package/src/{tasks → work}/detail/detail-types.ts +0 -0
  89. /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
  90. /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
  91. /package/src/{tasks → work}/detail/task-property.svelte +0 -0
  92. /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
  93. /package/src/{tasks → work}/gantt/gantt-grid.svelte +0 -0
  94. /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
  95. /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
  96. /package/src/{tasks → work}/gantt/gantt.css +0 -0
  97. /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
  98. /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
  99. /package/src/{tasks → work}/gantt/logic.ts +0 -0
  100. /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
  101. /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
  102. /package/src/{tasks → work}/inbox/index.ts +0 -0
  103. /package/src/{tasks → work}/kanban-column.svelte +0 -0
  104. /package/src/{tasks → work}/kanban-drag.ts +0 -0
  105. /package/src/{tasks → work}/kanban-types.ts +0 -0
  106. /package/src/{tasks → work}/presets/sales.ts +0 -0
  107. /package/src/{tasks → work}/reorder.ts +0 -0
  108. /package/src/{tasks → work}/task-gantt.svelte +0 -0
  109. /package/src/{tasks → work}/task-types.ts +0 -0
  110. /package/src/{tasks → work}/time/duration.ts +0 -0
  111. /package/src/{tasks → work}/time/time-chip.svelte +0 -0
  112. /package/src/{tasks → work}/time/time-dot.svelte +0 -0
  113. /package/src/{tasks → work}/time/time-ring.svelte +0 -0
  114. /package/src/{tasks → work}/time/time-types.ts +0 -0
  115. /package/src/{tasks → work}/use-kanban-dnd.svelte.ts +0 -0
@@ -1,9 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import { PencilSimple, Trash } from "phosphor-svelte";
4
- import { cn } from "../../lib/utils.js";
4
+ import { initial } from "../../lib/initials.js";
5
5
  import { AgentAvatar } from "../../agents/index.js";
6
- import { TONE_CLASS } from "../../lib/tone.js";
7
6
  import { Skeleton } from "../../shadcn/skeleton/index.js";
8
7
  import {
9
8
  DEFAULT_TASK_DETAIL_LABELS,
@@ -22,6 +21,8 @@
22
21
  avatar?: Snippet<[TaskComment]>;
23
22
  /** How a timestamp reads. Defaults to a short date. */
24
23
  when?: (comment: TaskComment) => string;
24
+ /** The locale the default times read in. */
25
+ locale?: string;
25
26
  /** Returns false to keep the draft. Without it the composer is hidden. */
26
27
  onSend?: (body: string) => void | boolean | Promise<void | boolean>;
27
28
  onEdit?: (comment: TaskComment, body: string) => void | Promise<void>;
@@ -35,8 +36,11 @@
35
36
  labels: labelOverrides,
36
37
  agentAvatar,
37
38
  avatar,
39
+ locale = "en-GB",
38
40
  when = (comment) =>
39
- new Date(comment.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
41
+ new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(
42
+ new Date(comment.createdAt),
43
+ ),
40
44
  onSend,
41
45
  onEdit,
42
46
  onDelete,
@@ -79,10 +83,6 @@
79
83
  if (!body || body === comment.body) return;
80
84
  await onEdit?.(comment, body);
81
85
  }
82
-
83
- function initial(name: string): string {
84
- return name.trim().charAt(0).toLocaleUpperCase() || "?";
85
- }
86
86
  </script>
87
87
 
88
88
  <section aria-label={labels.comments} class="flex flex-col gap-3">
@@ -132,14 +132,7 @@
132
132
  <div class="flex h-6 items-center gap-2">
133
133
  <span class="truncate text-[13px] font-medium">{comment.author}</span>
134
134
  {#if comment.agent}
135
- <span
136
- class={cn(
137
- "inline-flex h-5 items-center rounded-sm border px-1.5 text-xs font-medium",
138
- TONE_CLASS.accent,
139
- )}
140
- >
141
- {labels.ai}
142
- </span>
135
+ <span class="sr-only">{labels.ai}</span>
143
136
  {/if}
144
137
  <span class="text-xs text-muted-foreground tabular-nums">{when(comment)}</span>
145
138
  {#if comment.edited}
@@ -0,0 +1,194 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Editor } from "@tiptap/core";
6
+ import { FileText } from "phosphor-svelte";
7
+ import type { Snippet } from "svelte";
8
+ import OnThisPage from "../../components/docs/on-this-page.svelte";
9
+ import { ScrollSpy } from "../../components/docs/scroll-spy.svelte.js";
10
+ import RichEditor from "../../editor/editor.svelte";
11
+ import { docLabels, type DocLabels } from "./labels.js";
12
+ import { outlineOfDoc, type OutlineHeading } from "./outline.js";
13
+
14
+ // =========================
15
+ // TYPES
16
+ // =========================
17
+ interface Props {
18
+ /** The title, edited in place. */
19
+ title: string;
20
+ /** The body as Tiptap JSON. Read once: wrap the page in `{#key doc.id}` to swap documents. */
21
+ content?: Record<string, unknown> | null;
22
+ onchange?: (json: Record<string, unknown>) => void;
23
+ ontitle?: (title: string) => void;
24
+ /** Focus the title on open: a doc that was just created. */
25
+ autofocusTitle?: boolean;
26
+ /** Parent icon, parent title › this doc. */
27
+ breadcrumb?: Snippet;
28
+ /** Sits beside the breadcrumb: the star, the `…` menu. */
29
+ menu?: Snippet;
30
+ /** Right side of the bar, before the actions: the "Edited" chip. */
31
+ meta?: Snippet;
32
+ /** Right side of the bar: copy link, subscribe. */
33
+ actions?: Snippet;
34
+ /** The tile above the title. Defaults to a document glyph. */
35
+ icon?: Snippet;
36
+ /** A heading's link was copied; show the toast here. */
37
+ oncopylink?: (url: string, id: string) => void;
38
+ labels?: Partial<DocLabels>;
39
+ /** The live editor, to focus it or read it. */
40
+ instance?: Editor | null;
41
+ }
42
+
43
+ // =========================
44
+ // PROPS
45
+ // =========================
46
+ let {
47
+ title = $bindable(""),
48
+ content = null,
49
+ onchange,
50
+ ontitle,
51
+ autofocusTitle = false,
52
+ breadcrumb,
53
+ menu,
54
+ meta,
55
+ actions,
56
+ icon,
57
+ oncopylink,
58
+ labels: labelOverrides,
59
+ instance = $bindable(null),
60
+ }: Props = $props();
61
+
62
+ // =========================
63
+ // STATE
64
+ // =========================
65
+ let scroller = $state<HTMLElement | null>(null);
66
+ let titleField = $state<HTMLTextAreaElement | null>(null);
67
+ let outline = $state<OutlineHeading[]>(outlineOfDoc(untrackedContent()));
68
+ const spy = new ScrollSpy();
69
+
70
+ // =========================
71
+ // DERIVED
72
+ // =========================
73
+ let t = $derived(docLabels(labelOverrides));
74
+
75
+ // =========================
76
+ // EFFECTS
77
+ // =========================
78
+ $effect(() => spy.attach(scroller, outline));
79
+
80
+ $effect(() => {
81
+ if (autofocusTitle) titleField?.focus();
82
+ });
83
+
84
+ // =========================
85
+ // FUNCTIONS
86
+ // =========================
87
+ // The first outline reads the prop once; later ones come from the editor's own changes.
88
+ function untrackedContent() {
89
+ return content;
90
+ }
91
+
92
+ function handleChange(json: Record<string, unknown>) {
93
+ outline = outlineOfDoc(json);
94
+ onchange?.(json);
95
+ }
96
+
97
+ function onTitleKey(event: KeyboardEvent) {
98
+ if (event.key === "Enter" || (event.key === "ArrowDown" && !event.shiftKey)) {
99
+ event.preventDefault();
100
+ instance?.commands.focus("start");
101
+ }
102
+ }
103
+ </script>
104
+
105
+ <!-- =========================== -->
106
+ <!-- MARKUP -->
107
+ <!-- =========================== -->
108
+ <div class="@container/doc flex h-full min-h-0 flex-col bg-background text-foreground">
109
+ <header
110
+ class="flex h-11 shrink-0 items-center gap-2 border-b border-border/70 px-3 @2xl/doc:px-4"
111
+ >
112
+ <div class="flex min-w-0 flex-1 items-center gap-1 text-[13px]">
113
+ {#if breadcrumb}{@render breadcrumb()}{/if}
114
+ {#if menu}{@render menu()}{/if}
115
+ </div>
116
+ <div class="flex shrink-0 items-center gap-1">
117
+ {#if meta}{@render meta()}{/if}
118
+ {#if actions}{@render actions()}{/if}
119
+ </div>
120
+ </header>
121
+
122
+ <div class="relative min-h-0 flex-1">
123
+ {#if outline.length > 0}
124
+ <!-- Wide: ticks in the left gutter, centred on the pane. -->
125
+ <div class="absolute top-1/2 left-1 z-20 hidden -translate-y-1/2 @4xl/doc:block">
126
+ <OnThisPage
127
+ variant="ticks"
128
+ items={outline}
129
+ active={spy.active}
130
+ label={t.outline}
131
+ onselect={(id) => spy.jump(id)}
132
+ />
133
+ </div>
134
+ <!-- Narrow: the strip the reading pages use, since a phone has no gutter. -->
135
+ <div
136
+ class="absolute inset-x-0 top-0 z-20 border-b border-border/70 bg-background/95 backdrop-blur @4xl/doc:hidden"
137
+ >
138
+ <OnThisPage
139
+ variant="bar"
140
+ items={outline}
141
+ active={spy.active}
142
+ label={t.outline}
143
+ onselect={(id) => spy.jump(id)}
144
+ />
145
+ </div>
146
+ {/if}
147
+
148
+ <div bind:this={scroller} class="thin-scrollbar h-full overflow-y-auto">
149
+ <div
150
+ class="mx-auto w-full max-w-[56rem] px-5 pb-40 @2xl/doc:px-8 {outline.length > 0
151
+ ? 'pt-[3.75rem] @4xl/doc:pt-14'
152
+ : 'pt-8 @2xl/doc:pt-14'}"
153
+ >
154
+ <div
155
+ class="mb-4 flex size-12 items-center justify-center rounded-xl border border-border bg-muted/40 text-muted-foreground"
156
+ >
157
+ {#if icon}{@render icon()}{:else}<FileText size={22} aria-hidden="true" />{/if}
158
+ </div>
159
+
160
+ <textarea
161
+ bind:this={titleField}
162
+ bind:value={title}
163
+ oninput={() => ontitle?.(title)}
164
+ onkeydown={onTitleKey}
165
+ rows="1"
166
+ spellcheck="false"
167
+ aria-label={t.titlePlaceholder}
168
+ placeholder={t.titlePlaceholder}
169
+ class="doc-title mb-6 block w-full resize-none border-0 bg-transparent p-0 font-heading text-2xl leading-8 font-semibold tracking-tight text-foreground caret-brand outline-none focus-visible:shadow-none placeholder:text-muted-foreground/45"
170
+ ></textarea>
171
+
172
+ <RichEditor
173
+ {content}
174
+ onchange={handleChange}
175
+ variant="page"
176
+ preset="full"
177
+ placeholder=""
178
+ minHeight="20rem"
179
+ copyLinkLabel={t.copyLink}
180
+ {oncopylink}
181
+ bind:instance
182
+ />
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <style>
189
+ /* The title grows with its text, so a long one wraps instead of scrolling sideways. */
190
+ .doc-title {
191
+ field-sizing: content;
192
+ text-wrap: balance;
193
+ }
194
+ </style>
@@ -0,0 +1,333 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import {
6
+ ArrowsOutCardinal,
7
+ CaretLeft,
8
+ CaretRight,
9
+ ClockCounterClockwise,
10
+ Alarm,
11
+ Clipboard,
12
+ CopySimple,
13
+ DotsThree,
14
+ FileDashed,
15
+ PencilSimple,
16
+ Star,
17
+ Trash,
18
+ } from "phosphor-svelte";
19
+ import type { Component, Snippet } from "svelte";
20
+ import {
21
+ AlertDialog,
22
+ AlertDialogAction,
23
+ AlertDialogCancel,
24
+ AlertDialogContent,
25
+ AlertDialogDescription,
26
+ AlertDialogFooter,
27
+ AlertDialogHeader,
28
+ AlertDialogTitle,
29
+ } from "../../shadcn/alert-dialog/index.js";
30
+ import { buttonVariants } from "../../shadcn/button/index.js";
31
+ import {
32
+ Command,
33
+ CommandEmpty,
34
+ CommandGroup,
35
+ CommandInput,
36
+ CommandItem,
37
+ CommandList,
38
+ CommandSeparator,
39
+ CommandShortcut,
40
+ } from "../../shadcn/command/index.js";
41
+ import { Popover, PopoverContent, PopoverTrigger } from "../../shadcn/popover/index.js";
42
+ import { docLabels, type DocLabels } from "./labels.js";
43
+ import {
44
+ copyOptions,
45
+ deleteName,
46
+ filterMenu,
47
+ menuItems,
48
+ shortcutLabel,
49
+ shortcutTarget,
50
+ type CopyKind,
51
+ type MenuId,
52
+ } from "./menu.js";
53
+
54
+ // =========================
55
+ // TYPES
56
+ // =========================
57
+ interface Props {
58
+ /** The doc's title. An empty one reads "Untitled" in the delete confirm. */
59
+ title: string;
60
+ /** The doc's address; "Copy URL" and "title as link" need it. */
61
+ url?: string;
62
+ /** The doc as Tiptap JSON; "Copy content as Markdown" needs it. */
63
+ content?: unknown;
64
+ favorited?: boolean;
65
+ /** Every item is a callback. One that isn't passed doesn't show. */
66
+ onmove?: () => void;
67
+ onduplicate?: () => void;
68
+ onrename?: () => void;
69
+ ontemplate?: () => void;
70
+ onfavorite?: () => void;
71
+ /** Passing it shows Copy. It runs after the text is on the clipboard, for a toast. */
72
+ oncopy?: (kind: CopyKind, text: string) => void;
73
+ onremind?: () => void;
74
+ onhistory?: () => void;
75
+ /** Passing it shows Delete, behind a confirm that names the doc. */
76
+ ondelete?: () => void;
77
+ /** One line under the confirm title: "and its 3 sub-pages". */
78
+ deleteDetail?: string;
79
+ /** Fire items from the keyboard (⇧R) while no field has focus. */
80
+ shortcuts?: boolean;
81
+ open?: boolean;
82
+ labels?: Partial<DocLabels>;
83
+ /** Replaces the round `…` button. Spread `props` on the element. */
84
+ trigger?: Snippet<[Record<string, unknown>]>;
85
+ }
86
+
87
+ // =========================
88
+ // PROPS
89
+ // =========================
90
+ let {
91
+ title,
92
+ url,
93
+ content,
94
+ favorited = false,
95
+ onmove,
96
+ onduplicate,
97
+ onrename,
98
+ ontemplate,
99
+ onfavorite,
100
+ oncopy,
101
+ onremind,
102
+ onhistory,
103
+ ondelete,
104
+ deleteDetail,
105
+ shortcuts = true,
106
+ open = $bindable(false),
107
+ labels: labelOverrides,
108
+ trigger,
109
+ }: Props = $props();
110
+
111
+ // =========================
112
+ // STATE
113
+ // =========================
114
+ let query = $state("");
115
+ let page = $state<"root" | "copy">("root");
116
+ let confirming = $state(false);
117
+
118
+ // =========================
119
+ // DERIVED
120
+ // =========================
121
+ let t = $derived(docLabels(labelOverrides));
122
+ let all = $derived(
123
+ menuItems(
124
+ {
125
+ move: !!onmove,
126
+ duplicate: !!onduplicate,
127
+ rename: !!onrename,
128
+ template: !!ontemplate,
129
+ favorite: !!onfavorite,
130
+ copy: !!oncopy,
131
+ remind: !!onremind,
132
+ history: !!onhistory,
133
+ delete: !!ondelete,
134
+ },
135
+ t,
136
+ { favorited },
137
+ ),
138
+ );
139
+ let visible = $derived(filterMenu(all, query));
140
+ // Groups keep their order; a hairline sits between the ones that still have an item.
141
+ let groups = $derived.by(() => {
142
+ const byGroup = new Map<number, typeof visible>();
143
+ for (const item of visible) byGroup.set(item.group, [...(byGroup.get(item.group) ?? []), item]);
144
+ return [...byGroup.entries()].toSorted((a, b) => a[0] - b[0]).map(([, items]) => items);
145
+ });
146
+ // Built only while the copy list is showing: the Markdown of a long doc isn't free.
147
+ let copies = $derived(
148
+ page === "copy" ? filterMenu(copyOptions({ title, url, content }, t), query) : [],
149
+ );
150
+ let confirmName = $derived(deleteName(title, t));
151
+
152
+ const ICONS: Record<MenuId, Component<{ class?: string }>> = {
153
+ move: ArrowsOutCardinal,
154
+ duplicate: CopySimple,
155
+ rename: PencilSimple,
156
+ template: FileDashed,
157
+ favorite: Star,
158
+ copy: Clipboard,
159
+ remind: Alarm,
160
+ history: ClockCounterClockwise,
161
+ delete: Trash,
162
+ };
163
+
164
+ // =========================
165
+ // FUNCTIONS
166
+ // =========================
167
+ function reset() {
168
+ query = "";
169
+ page = "root";
170
+ }
171
+
172
+ function fire(id: MenuId) {
173
+ const run: Partial<Record<MenuId, (() => void) | undefined>> = {
174
+ move: onmove,
175
+ duplicate: onduplicate,
176
+ rename: onrename,
177
+ template: ontemplate,
178
+ favorite: onfavorite,
179
+ remind: onremind,
180
+ history: onhistory,
181
+ };
182
+ if (id === "copy") {
183
+ query = "";
184
+ page = "copy";
185
+ return;
186
+ }
187
+ open = false;
188
+ if (id === "delete") confirming = true;
189
+ else run[id]?.();
190
+ }
191
+
192
+ async function copy(kind: CopyKind, text: string) {
193
+ open = false;
194
+ // A blocked clipboard still reports, so the host can say the copy didn't land.
195
+ await navigator.clipboard.writeText(text).catch(() => {});
196
+ oncopy?.(kind, text);
197
+ }
198
+
199
+ function onWindowKey(event: KeyboardEvent) {
200
+ if (!shortcuts || event.defaultPrevented || event.repeat) return;
201
+ const target = event.target as HTMLElement | null;
202
+ if (target?.closest("input, textarea, select, [contenteditable='true'], [role='dialog']"))
203
+ return;
204
+ const id = shortcutTarget(event, all);
205
+ if (!id) return;
206
+ event.preventDefault();
207
+ fire(id);
208
+ }
209
+
210
+ function onListKey(event: KeyboardEvent) {
211
+ if (page !== "copy") return;
212
+ const empty = query === "";
213
+ if (event.key === "ArrowLeft" || (event.key === "Backspace" && empty)) {
214
+ if (event.key === "ArrowLeft" && !empty) return;
215
+ event.preventDefault();
216
+ reset();
217
+ }
218
+ }
219
+ </script>
220
+
221
+ <svelte:window onkeydown={onWindowKey} />
222
+
223
+ <!-- =========================== -->
224
+ <!-- MARKUP -->
225
+ <!-- =========================== -->
226
+ <Popover
227
+ bind:open
228
+ onOpenChange={(next) => {
229
+ // Reset as it opens, not as it closes: a reset mid-fade flashes the root list.
230
+ if (next) reset();
231
+ }}
232
+ >
233
+ <PopoverTrigger aria-label={t.menu}>
234
+ {#snippet child({ props })}
235
+ {#if trigger}
236
+ {@render trigger(props)}
237
+ {:else}
238
+ <button
239
+ {...props}
240
+ type="button"
241
+ class="t-press inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-foreground/6 aria-expanded:text-foreground any-pointer-coarse:size-10"
242
+ >
243
+ <DotsThree size={18} weight="bold" aria-hidden="true" />
244
+ </button>
245
+ {/if}
246
+ {/snippet}
247
+ </PopoverTrigger>
248
+ <PopoverContent align="start" sideOffset={6} class="w-64 gap-0 overflow-hidden p-0">
249
+ <Command shouldFilter={false} onkeydown={onListKey} class="rounded-xl">
250
+ <CommandInput
251
+ bind:value={query}
252
+ placeholder={page === "copy" ? t.copy : t.filter}
253
+ aria-label={t.filter}
254
+ />
255
+ <CommandList class="max-h-[22rem] p-1.5">
256
+ {#if page === "root"}
257
+ {#each groups as items, i (i)}
258
+ {#if i > 0 && !query}<CommandSeparator class="my-1.5" />{/if}
259
+ <CommandGroup class="p-0">
260
+ {#each items as item (item.id)}
261
+ {@const Icon = ICONS[item.id]}
262
+ <CommandItem
263
+ value={item.id}
264
+ onSelect={() => fire(item.id)}
265
+ class={item.danger ? "text-destructive data-selected:text-destructive" : ""}
266
+ >
267
+ <Icon
268
+ class={item.id === "favorite" && favorited
269
+ ? "text-brand"
270
+ : item.danger
271
+ ? ""
272
+ : "text-muted-foreground"}
273
+ />
274
+ <span class="flex-1 truncate">{item.label}</span>
275
+ {#if item.shortcut}
276
+ <CommandShortcut class="text-xs tracking-normal"
277
+ >{shortcutLabel(item.shortcut)}</CommandShortcut
278
+ >
279
+ {:else if item.submenu}
280
+ <CaretRight size={12} class="text-muted-foreground" aria-hidden="true" />
281
+ {/if}
282
+ </CommandItem>
283
+ {/each}
284
+ </CommandGroup>
285
+ {/each}
286
+ {#if visible.length === 0}
287
+ <CommandEmpty class="py-6 text-center text-[13px] text-muted-foreground">
288
+ {t.noResults}
289
+ </CommandEmpty>
290
+ {/if}
291
+ {:else}
292
+ <CommandGroup class="p-0">
293
+ {#if !query}
294
+ <CommandItem value="__back" onSelect={reset} class="text-muted-foreground">
295
+ <CaretLeft size={14} aria-hidden="true" />
296
+ <span>{t.back}</span>
297
+ </CommandItem>
298
+ <CommandSeparator class="my-1.5" />
299
+ {/if}
300
+ {#each copies as option (option.kind)}
301
+ <CommandItem value={option.kind} onSelect={() => copy(option.kind, option.text)}>
302
+ <span class="truncate">{option.label}</span>
303
+ </CommandItem>
304
+ {/each}
305
+ </CommandGroup>
306
+ {#if copies.length === 0}
307
+ <CommandEmpty class="py-6 text-center text-[13px] text-muted-foreground">
308
+ {t.noResults}
309
+ </CommandEmpty>
310
+ {/if}
311
+ {/if}
312
+ </CommandList>
313
+ </Command>
314
+ </PopoverContent>
315
+ </Popover>
316
+
317
+ <AlertDialog bind:open={confirming}>
318
+ <AlertDialogContent class="sm:max-w-sm">
319
+ <AlertDialogHeader>
320
+ <AlertDialogTitle>{t.deleteTitle(confirmName)}</AlertDialogTitle>
321
+ <AlertDialogDescription>{deleteDetail ?? t.deleteDetail}</AlertDialogDescription>
322
+ </AlertDialogHeader>
323
+ <AlertDialogFooter>
324
+ <AlertDialogCancel>{t.cancel}</AlertDialogCancel>
325
+ <AlertDialogAction
326
+ class={buttonVariants({ variant: "destructive" })}
327
+ onclick={() => ondelete?.()}
328
+ >
329
+ {t.delete}
330
+ </AlertDialogAction>
331
+ </AlertDialogFooter>
332
+ </AlertDialogContent>
333
+ </AlertDialog>