@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.
- package/COMPONENTS.md +1 -1
- package/README.md +1 -1
- package/package.json +58 -74
- package/src/agents/vendor/draw.ts +4 -4
- package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
- package/src/canvas/ops/ops.ts +1 -1
- package/src/charts/area-chart.svelte +11 -1
- package/src/charts/bar-chart.svelte +18 -3
- package/src/charts/line-chart.svelte +11 -1
- package/src/components/data-table/data-table.ts +4 -5
- package/src/components/data-table/pagination.svelte +5 -3
- package/src/components/docs/on-this-page.svelte +82 -5
- package/src/components/docs/scroll-spy.svelte.ts +6 -1
- package/src/components/list-page/detail-header.svelte +7 -3
- package/src/components/list-page/empty-state.svelte +3 -1
- package/src/components/list-page/filter-popover.svelte +2 -2
- package/src/components/list-page/rail-row.svelte +5 -2
- package/src/components/mark/mark.svelte +2 -1
- package/src/components/section-card/section-card.svelte +3 -1
- package/src/components/section-header/section-header.svelte +5 -5
- package/src/components/segmented/segment.svelte +1 -1
- package/src/components/table-view/table-view.svelte +6 -1
- package/src/components/tags-input/tags-input.svelte +1 -1
- package/src/components/toolbar/toolbar.svelte +1 -1
- package/src/editor/anchors.ts +28 -0
- package/src/editor/doc-blocks.ts +168 -0
- package/src/editor/doc-page.css +389 -0
- package/src/editor/editor.svelte +47 -5
- package/src/editor/extensions.ts +26 -2
- package/src/editor/markdown.ts +27 -2
- package/src/editor/slash-items.ts +18 -0
- package/src/icons/trades.ts +1 -1
- package/src/inbox/README.md +6 -4
- package/src/inbox/inbox.test.ts +1 -1
- package/src/inbox/index.ts +2 -2
- package/src/inbox/logic.ts +29 -5
- package/src/inbox/thread-inbox.svelte +219 -92
- package/src/inbox/types.ts +27 -1
- package/src/lib/initials.test.ts +25 -0
- package/src/lib/initials.ts +29 -0
- package/src/modules.test.ts +4 -64
- package/src/panel/README.md +1 -1
- package/src/record/README.md +1 -1
- package/src/record/info-list.svelte +5 -4
- package/src/record/next-up.svelte +1 -1
- package/src/record/project-list.svelte +1 -1
- package/src/record/steps-rail.svelte +3 -3
- package/src/shadcn/badge/badge.svelte +1 -1
- package/src/shadcn/card/card-title.svelte +1 -5
- package/src/shadcn/empty/empty-description.svelte +1 -1
- package/src/shadcn/progress/progress.svelte +8 -1
- package/src/shadcn/table/table-cell.svelte +10 -2
- package/src/status/README.md +1 -1
- package/src/thread/README.md +2 -0
- package/src/thread/index.ts +9 -1
- package/src/thread/logic.ts +33 -3
- package/src/thread/thread-body.svelte +37 -0
- package/src/thread/thread-events.svelte +30 -0
- package/src/thread/thread.svelte +74 -59
- package/src/thread/types.ts +21 -0
- package/src/{tasks → work}/README.md +11 -8
- package/src/{tasks → work}/detail/task-thread.svelte +8 -15
- package/src/work/docs/doc-editor-page.svelte +194 -0
- package/src/work/docs/doc-menu.svelte +333 -0
- package/src/work/docs/docs-demo.svelte +227 -0
- package/src/work/docs/edited-chip.svelte +125 -0
- package/src/work/docs/edited.ts +54 -0
- package/src/work/docs/index.ts +38 -0
- package/src/work/docs/labels.ts +70 -0
- package/src/work/docs/menu.ts +127 -0
- package/src/work/docs/outline.ts +60 -0
- package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
- package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
- package/src/work/inbox/inbox-logic.ts +98 -0
- package/src/work/inbox/inbox-trace.svelte +58 -0
- package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
- package/src/{tasks → work}/index.ts +0 -10
- package/src/{tasks → work}/kanban-board.svelte +1 -1
- package/src/{tasks → work}/task-list.svelte +1 -1
- package/src/{tasks → work}/time/time-tracker.svelte +2 -5
- package/src/tasks/inbox/inbox-logic.ts +0 -21
- package/src/tasks/inbox/inbox-trace.svelte +0 -51
- /package/src/{tasks → work}/ambient.d.ts +0 -0
- /package/src/{tasks → work}/board-order.ts +0 -0
- /package/src/{tasks → work}/dates.ts +0 -0
- /package/src/{tasks → work}/demo.svelte +0 -0
- /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
- /package/src/{tasks → work}/detail/detail-types.ts +0 -0
- /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
- /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
- /package/src/{tasks → work}/detail/task-property.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-grid.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt.css +0 -0
- /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
- /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
- /package/src/{tasks → work}/gantt/logic.ts +0 -0
- /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
- /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
- /package/src/{tasks → work}/inbox/index.ts +0 -0
- /package/src/{tasks → work}/kanban-column.svelte +0 -0
- /package/src/{tasks → work}/kanban-drag.ts +0 -0
- /package/src/{tasks → work}/kanban-types.ts +0 -0
- /package/src/{tasks → work}/presets/sales.ts +0 -0
- /package/src/{tasks → work}/reorder.ts +0 -0
- /package/src/{tasks → work}/task-gantt.svelte +0 -0
- /package/src/{tasks → work}/task-types.ts +0 -0
- /package/src/{tasks → work}/time/duration.ts +0 -0
- /package/src/{tasks → work}/time/time-chip.svelte +0 -0
- /package/src/{tasks → work}/time/time-dot.svelte +0 -0
- /package/src/{tasks → work}/time/time-ring.svelte +0 -0
- /package/src/{tasks → work}/time/time-types.ts +0 -0
- /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 {
|
|
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
|
|
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>
|