visual-comments 0.0.0-stage → 0.1.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -2
  3. package/dist/core/aim.d.ts +21 -0
  4. package/dist/core/aim.js +21 -0
  5. package/dist/core/api.d.ts +5 -0
  6. package/dist/core/api.js +11 -0
  7. package/dist/core/metaball.d.ts +15 -0
  8. package/dist/core/metaball.js +42 -0
  9. package/dist/core/surfaces.d.ts +95 -0
  10. package/dist/core/surfaces.js +197 -0
  11. package/dist/core/target.d.ts +59 -0
  12. package/dist/core/target.js +382 -0
  13. package/dist/core/threads.svelte.d.ts +46 -0
  14. package/dist/core/threads.svelte.js +219 -0
  15. package/dist/core/time.d.ts +3 -0
  16. package/dist/core/time.js +25 -0
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.js +6 -0
  19. package/dist/server/index.d.ts +33 -0
  20. package/dist/server/index.js +67 -0
  21. package/dist/shared/limits.d.ts +9 -0
  22. package/dist/shared/limits.js +10 -0
  23. package/dist/shared/protocol.d.ts +2 -0
  24. package/dist/shared/protocol.js +3 -0
  25. package/dist/shared/types.d.ts +93 -0
  26. package/dist/shared/types.js +2 -0
  27. package/dist/ui/Account.svelte +68 -0
  28. package/dist/ui/Account.svelte.d.ts +8 -0
  29. package/dist/ui/App.svelte +68 -0
  30. package/dist/ui/App.svelte.d.ts +7 -0
  31. package/dist/ui/Avatar.svelte +42 -0
  32. package/dist/ui/Avatar.svelte.d.ts +12 -0
  33. package/dist/ui/Composer.svelte +267 -0
  34. package/dist/ui/Composer.svelte.d.ts +20 -0
  35. package/dist/ui/Icon.svelte +44 -0
  36. package/dist/ui/Icon.svelte.d.ts +29 -0
  37. package/dist/ui/Menu.svelte +65 -0
  38. package/dist/ui/Menu.svelte.d.ts +13 -0
  39. package/dist/ui/MenuItem.svelte +38 -0
  40. package/dist/ui/MenuItem.svelte.d.ts +12 -0
  41. package/dist/ui/Overlay.svelte +488 -0
  42. package/dist/ui/Overlay.svelte.d.ts +3 -0
  43. package/dist/ui/Panel.svelte +206 -0
  44. package/dist/ui/Panel.svelte.d.ts +10 -0
  45. package/dist/ui/Pin.svelte +136 -0
  46. package/dist/ui/Pin.svelte.d.ts +13 -0
  47. package/dist/ui/ThreadCard.svelte +177 -0
  48. package/dist/ui/ThreadCard.svelte.d.ts +13 -0
  49. package/dist/ui/Toolbar.svelte +35 -0
  50. package/dist/ui/Toolbar.svelte.d.ts +18 -0
  51. package/dist/ui/context.d.ts +3 -0
  52. package/dist/ui/context.js +8 -0
  53. package/dist/ui/generated/styles.css +2 -0
  54. package/dist/ui/generated/styles.d.ts +2 -0
  55. package/dist/ui/generated/styles.js +3 -0
  56. package/dist/ui/styles.css +92 -0
  57. package/dist/ui/text.d.ts +3 -0
  58. package/dist/ui/text.js +7 -0
  59. package/dist/visual-comments.svelte.d.ts +181 -0
  60. package/dist/visual-comments.svelte.js +568 -0
  61. package/dist-widget/visual-comments.js +51 -0
  62. package/package.json +66 -5
@@ -0,0 +1,267 @@
1
+ <!--
2
+ A comment field that grows with its text. Enter sends, Shift+Enter starts a
3
+ new line, Escape cancels. The text clears at once and comes back if sending fails.
4
+ Images dropped on it or pasted into it upload at once and show as thumbnails;
5
+ a comment can be images alone.
6
+ -->
7
+ <script lang="ts">
8
+ import { onDestroy, onMount } from 'svelte';
9
+ import { useComments } from './context.js';
10
+ import type { CommentImage } from '../shared/types.js';
11
+ import Icon from './Icon.svelte';
12
+ import { IMAGE_TYPES, MAX_BODY, MAX_IMAGE_BYTES, MAX_IMAGES } from '../shared/limits.js';
13
+
14
+ let {
15
+ variant = 'reply',
16
+ placeholder = 'Reply',
17
+ initial = '',
18
+ initialImages = [],
19
+ autofocus = false,
20
+ onsubmit,
21
+ oncancel,
22
+ oninput,
23
+ onimages
24
+ }: {
25
+ /** `new` floats beside its pin; `reply` and `edit` sit inside a thread. */
26
+ variant?: 'new' | 'reply' | 'edit';
27
+ placeholder?: string;
28
+ initial?: string;
29
+ /** The images of a comment being edited. */
30
+ initialImages?: CommentImage[];
31
+ autofocus?: boolean;
32
+ onsubmit: (body: string, images: CommentImage[]) => Promise<unknown> | unknown;
33
+ oncancel?: () => void;
34
+ oninput?: (value: string) => void;
35
+ /** How many images the field holds, as it changes. */
36
+ onimages?: (count: number) => void;
37
+ } = $props();
38
+
39
+ interface Attachment {
40
+ key: number;
41
+ /** A local object URL while it uploads, then wherever it is shown from. */
42
+ preview: string;
43
+ /** Set once uploaded. */
44
+ image?: CommentImage;
45
+ error?: string;
46
+ }
47
+
48
+ const comments = useComments().store;
49
+ // svelte-ignore state_referenced_locally
50
+ let value = $state(initial);
51
+ // svelte-ignore state_referenced_locally
52
+ let attachments = $state<Attachment[]>(initialImages.map((image, key) => ({ key, preview: comments.imageUrl(image), image })));
53
+ // svelte-ignore state_referenced_locally
54
+ let nextKey = initialImages.length;
55
+ let sending = $state(false);
56
+ let dragging = $state(false);
57
+ let field = $state<HTMLTextAreaElement>();
58
+ let form = $state<HTMLFormElement>();
59
+ const uploading = $derived(attachments.some((a) => !a.image && !a.error));
60
+ const empty = $derived(!value.trim() && !attachments.some((a) => a.image));
61
+ const blocked = $derived(empty || sending || uploading || attachments.some((a) => a.error));
62
+
63
+ $effect(() => onimages?.(attachments.length));
64
+
65
+ export function focus() {
66
+ field?.focus();
67
+ }
68
+
69
+ function fit() {
70
+ if (!field) return;
71
+ field.style.height = 'auto';
72
+ field.style.height = `${Math.min(field.scrollHeight, 160)}px`;
73
+ }
74
+
75
+ function add(files: Iterable<File>) {
76
+ for (const file of files) {
77
+ if (!IMAGE_TYPES.includes(file.type)) {
78
+ comments.error = 'Only PNG, JPEG, GIF and WebP images can be added.';
79
+ continue;
80
+ }
81
+ if (attachments.length >= MAX_IMAGES) {
82
+ comments.error = `A comment can have up to ${MAX_IMAGES} images.`;
83
+ break;
84
+ }
85
+ const key = nextKey++;
86
+ attachments.push({ key, preview: URL.createObjectURL(file) });
87
+ void upload(key, file);
88
+ }
89
+ field?.focus();
90
+ }
91
+
92
+ async function upload(key: number, file: File) {
93
+ const update = (change: Partial<Attachment>) => {
94
+ const attachment = attachments.find((a) => a.key === key);
95
+ if (attachment) Object.assign(attachment, change);
96
+ };
97
+ if (file.size > MAX_IMAGE_BYTES) {
98
+ update({ error: `Larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB` });
99
+ return;
100
+ }
101
+ try {
102
+ const bitmap = await createImageBitmap(file).catch(() => {
103
+ throw new Error('Not a readable image');
104
+ });
105
+ const { width, height } = bitmap;
106
+ bitmap.close();
107
+ update({ image: await comments.upload(file, width, height) });
108
+ } catch (error) {
109
+ update({ error: (error as Error).message });
110
+ }
111
+ }
112
+
113
+ function release(list: Attachment[]) {
114
+ for (const a of list) if (a.preview.startsWith('blob:')) URL.revokeObjectURL(a.preview);
115
+ }
116
+
117
+ function remove(key: number) {
118
+ release(attachments.filter((a) => a.key === key));
119
+ attachments = attachments.filter((a) => a.key !== key);
120
+ field?.focus();
121
+ }
122
+
123
+ onDestroy(() => release(attachments));
124
+
125
+ const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false;
126
+
127
+ function ondragover(event: DragEvent) {
128
+ if (!carriesFiles(event)) return;
129
+ event.preventDefault();
130
+ event.dataTransfer!.dropEffect = 'copy';
131
+ dragging = true;
132
+ }
133
+
134
+ function ondragleave(event: DragEvent) {
135
+ if (!form?.contains(event.relatedTarget as Node | null)) dragging = false;
136
+ }
137
+
138
+ function ondrop(event: DragEvent) {
139
+ if (!carriesFiles(event)) return;
140
+ event.preventDefault();
141
+ dragging = false;
142
+ add(event.dataTransfer!.files);
143
+ }
144
+
145
+ function onpaste(event: ClipboardEvent) {
146
+ const files = [...(event.clipboardData?.files ?? [])].filter((file) => file.type.startsWith('image/'));
147
+ if (!files.length) return;
148
+ event.preventDefault();
149
+ add(files);
150
+ }
151
+
152
+ async function submit(event?: Event) {
153
+ event?.preventDefault();
154
+ if (blocked) return;
155
+ const body = value;
156
+ const sent = attachments;
157
+ sending = true;
158
+ if (variant !== 'edit') {
159
+ value = '';
160
+ attachments = [];
161
+ }
162
+ requestAnimationFrame(fit);
163
+ try {
164
+ await onsubmit(body.trim(), sent.map((a) => a.image!));
165
+ if (variant !== 'edit') release(sent);
166
+ } catch {
167
+ value = body;
168
+ attachments = sent;
169
+ requestAnimationFrame(fit);
170
+ } finally {
171
+ sending = false;
172
+ field?.focus();
173
+ }
174
+ }
175
+
176
+ function onkeydown(event: KeyboardEvent) {
177
+ if (event.isComposing) return;
178
+ if (event.key === 'Enter' && !event.shiftKey) {
179
+ event.preventDefault();
180
+ void submit();
181
+ } else if (event.key === 'Escape') {
182
+ event.preventDefault();
183
+ event.stopPropagation();
184
+ oncancel?.();
185
+ }
186
+ }
187
+
188
+ onMount(() => {
189
+ fit();
190
+ if (autofocus) {
191
+ field?.focus({ preventScroll: true });
192
+ field?.setSelectionRange(value.length, value.length);
193
+ }
194
+ });
195
+ </script>
196
+
197
+ <form
198
+ bind:this={form}
199
+ onsubmit={submit}
200
+ {ondragover}
201
+ ondragenter={ondragover}
202
+ {ondragleave}
203
+ {ondrop}
204
+ class={[
205
+ 'flex flex-col bg-white text-[13px] text-vc-ink transition-colors',
206
+ variant === 'new'
207
+ ? 'w-[296px] rounded-[18px] border border-vc-comment py-[5px] pr-[5px] pl-3.5 shadow-[0_2px_14px_rgb(0_0_0/0.18)]'
208
+ : 'rounded-[14px] border border-vc-line py-[3px] pr-[3px] pl-3 focus-within:border-vc-comment',
209
+ dragging && 'border-vc-comment! bg-vc-comment-soft ring-2 ring-vc-comment/30'
210
+ ]}
211
+ >
212
+ {#if attachments.length}
213
+ <ul class="flex flex-wrap gap-1.5 pt-1.5 pr-1.5 pb-1" aria-label="Images">
214
+ {#each attachments as attachment (attachment.key)}
215
+ <li class="relative size-14 overflow-hidden rounded-lg border border-vc-line bg-vc-surface" title={attachment.error}>
216
+ <img src={attachment.preview} alt="" class={['size-full object-cover', !attachment.image && 'opacity-50']} />
217
+ {#if attachment.error}
218
+ <span class="absolute inset-0 grid place-items-center bg-white/85 px-1 text-center text-[10px] leading-tight font-medium text-red-600">
219
+ Not added
220
+ </span>
221
+ {:else if !attachment.image}
222
+ <span class="absolute inset-0 m-auto size-4 animate-spin rounded-full border-2 border-vc-comment border-t-transparent" aria-label="Uploading"></span>
223
+ {/if}
224
+ <button
225
+ type="button"
226
+ class="absolute top-0.5 right-0.5 grid size-4 place-items-center rounded-full bg-black/60 text-white hover:bg-black/80"
227
+ aria-label="Remove image"
228
+ title="Remove"
229
+ onclick={() => remove(attachment.key)}
230
+ >
231
+ <Icon name="close" size={10} />
232
+ </button>
233
+ </li>
234
+ {/each}
235
+ </ul>
236
+ {/if}
237
+ <div class="flex items-end gap-2">
238
+ <textarea
239
+ bind:this={field}
240
+ bind:value
241
+ rows="1"
242
+ {placeholder}
243
+ aria-label={placeholder}
244
+ maxlength={MAX_BODY}
245
+ {onkeydown}
246
+ {onpaste}
247
+ oninput={() => { fit(); oninput?.(value); }}
248
+ class="max-h-40 min-w-0 flex-1 resize-none self-center bg-transparent py-1 leading-5 outline-none placeholder:text-vc-muted/70"
249
+ ></textarea>
250
+ {#if variant === 'edit'}
251
+ <button type="button" class="h-6 rounded-full px-2 text-xs text-vc-muted hover:bg-vc-wash" onclick={() => oncancel?.()}>Cancel</button>
252
+ {/if}
253
+ <button
254
+ type="submit"
255
+ disabled={blocked}
256
+ aria-label={variant === 'edit' ? 'Save' : 'Send'}
257
+ title={uploading ? 'Uploading images…' : variant === 'edit' ? 'Save (Enter)' : 'Send (Enter)'}
258
+ class={[
259
+ 'grid shrink-0 place-items-center rounded-full text-white transition-colors',
260
+ variant === 'new' ? 'size-7' : 'size-6',
261
+ blocked ? 'bg-[#d9d9d9]' : 'bg-vc-comment hover:brightness-110'
262
+ ]}
263
+ >
264
+ <Icon name={variant === 'edit' ? 'check' : 'send'} size={variant === 'new' ? 16 : 14} />
265
+ </button>
266
+ </div>
267
+ </form>
@@ -0,0 +1,20 @@
1
+ import type { CommentImage } from '../shared/types.js';
2
+ type $$ComponentProps = {
3
+ /** `new` floats beside its pin; `reply` and `edit` sit inside a thread. */
4
+ variant?: 'new' | 'reply' | 'edit';
5
+ placeholder?: string;
6
+ initial?: string;
7
+ /** The images of a comment being edited. */
8
+ initialImages?: CommentImage[];
9
+ autofocus?: boolean;
10
+ onsubmit: (body: string, images: CommentImage[]) => Promise<unknown> | unknown;
11
+ oncancel?: () => void;
12
+ oninput?: (value: string) => void;
13
+ /** How many images the field holds, as it changes. */
14
+ onimages?: (count: number) => void;
15
+ };
16
+ declare const Composer: import("svelte").Component<$$ComponentProps, {
17
+ focus: () => void;
18
+ }, "">;
19
+ type Composer = ReturnType<typeof Composer>;
20
+ export default Composer;
@@ -0,0 +1,44 @@
1
+ <script lang="ts" module>
2
+ // 24×24 stroke icons.
3
+ const PATHS = {
4
+ canvas:
5
+ 'M3 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M3 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0',
6
+ 'chevron-left': 'M15 6l-6 6 6 6',
7
+ 'chevron-right': 'M9 6l6 6-6 6',
8
+ 'chevron-down': 'M6 9l6 6 6-6',
9
+ 'chevron-up': 'M6 15l6-6 6 6',
10
+ play: 'M7 5l12 7-12 7z',
11
+ external: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
12
+ restart: 'M4 12a8 8 0 1 0 2.4-5.7M4 4v4h4',
13
+ check: 'M5 12.5l4.5 4.5L19 7.5',
14
+ plus: 'M12 5v14M5 12h14',
15
+ minus: 'M5 12h14',
16
+ fit: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
17
+ comment: 'M12 3.5a8.5 8.5 0 1 1 0 17H3.5V12A8.5 8.5 0 0 1 12 3.5z',
18
+ search: 'M11 4.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13zM20 20l-4.4-4.4',
19
+ filter: 'M4 7h16M7 12h10M10 17h4',
20
+ more: 'M5 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM12 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM19 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2z',
21
+ resolve: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18zM8 12.2l2.7 2.7L16 9.6',
22
+ close: 'M6 6l12 12M18 6L6 18',
23
+ send: 'M12 19V5M6.5 10.5L12 5l5.5 5.5'
24
+ } as const;
25
+ export type IconName = keyof typeof PATHS;
26
+ </script>
27
+
28
+ <script lang="ts">
29
+ let { name, size = 16 }: { name: IconName; size?: number } = $props();
30
+ </script>
31
+
32
+ <svg
33
+ width={size}
34
+ height={size}
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="1.75"
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ aria-hidden="true"
42
+ >
43
+ <path d={PATHS[name]} />
44
+ </svg>
@@ -0,0 +1,29 @@
1
+ declare const PATHS: {
2
+ readonly canvas: "M3 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 6.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M3 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 17.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0";
3
+ readonly 'chevron-left': "M15 6l-6 6 6 6";
4
+ readonly 'chevron-right': "M9 6l6 6-6 6";
5
+ readonly 'chevron-down': "M6 9l6 6 6-6";
6
+ readonly 'chevron-up': "M6 15l6-6 6 6";
7
+ readonly play: "M7 5l12 7-12 7z";
8
+ readonly external: "M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5";
9
+ readonly restart: "M4 12a8 8 0 1 0 2.4-5.7M4 4v4h4";
10
+ readonly check: "M5 12.5l4.5 4.5L19 7.5";
11
+ readonly plus: "M12 5v14M5 12h14";
12
+ readonly minus: "M5 12h14";
13
+ readonly fit: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5";
14
+ readonly comment: "M12 3.5a8.5 8.5 0 1 1 0 17H3.5V12A8.5 8.5 0 0 1 12 3.5z";
15
+ readonly search: "M11 4.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 1 0 0-13zM20 20l-4.4-4.4";
16
+ readonly filter: "M4 7h16M7 12h10M10 17h4";
17
+ readonly more: "M5 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM12 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2zM19 11a1 1 0 1 0 0 2a1 1 0 1 0 0-2z";
18
+ readonly resolve: "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18zM8 12.2l2.7 2.7L16 9.6";
19
+ readonly close: "M6 6l12 12M18 6L6 18";
20
+ readonly send: "M12 19V5M6.5 10.5L12 5l5.5 5.5";
21
+ };
22
+ export type IconName = keyof typeof PATHS;
23
+ type $$ComponentProps = {
24
+ name: IconName;
25
+ size?: number;
26
+ };
27
+ declare const Icon: import("svelte").Component<$$ComponentProps, {}, "">;
28
+ type Icon = ReturnType<typeof Icon>;
29
+ export default Icon;
@@ -0,0 +1,65 @@
1
+ <!-- A button that opens a popover menu below it. Items close the menu with `close()`. -->
2
+ <script lang="ts">
3
+ import type { Snippet } from 'svelte';
4
+
5
+ let {
6
+ label,
7
+ trigger,
8
+ children,
9
+ align = 'start',
10
+ tone = 'light',
11
+ class: className = ''
12
+ }: {
13
+ /** Accessible name for the trigger. */
14
+ label: string;
15
+ trigger: Snippet;
16
+ children: Snippet<[close: () => void]>;
17
+ align?: 'start' | 'end';
18
+ tone?: 'light' | 'dark';
19
+ class?: string;
20
+ } = $props();
21
+
22
+ const id = $props.id();
23
+ let button = $state<HTMLButtonElement>();
24
+ let popover = $state<HTMLDivElement>();
25
+ let open = $state(false);
26
+
27
+ function place(event: ToggleEvent) {
28
+ open = event.newState === 'open';
29
+ if (event.newState !== 'open' || !button || !popover) return;
30
+ const rect = button.getBoundingClientRect();
31
+ const gutter = 12;
32
+ const left = align === 'start' ? rect.left : rect.right - popover.offsetWidth;
33
+ popover.style.left = `${Math.max(gutter, Math.min(left, window.innerWidth - popover.offsetWidth - gutter))}px`;
34
+ popover.style.top = `${Math.max(gutter, Math.min(rect.bottom + 6, window.innerHeight - popover.offsetHeight - gutter))}px`;
35
+ }
36
+
37
+ const close = () => popover?.hidePopover();
38
+ </script>
39
+
40
+ <button
41
+ bind:this={button}
42
+ type="button"
43
+ popovertarget="vc-menu-{id}"
44
+ aria-label={label}
45
+ aria-expanded={open}
46
+ aria-controls="vc-menu-{id}"
47
+ title={label}
48
+ class={className}
49
+ >
50
+ {@render trigger()}
51
+ </button>
52
+ <div
53
+ bind:this={popover}
54
+ id="vc-menu-{id}"
55
+ popover="auto"
56
+ ontoggle={place}
57
+ class={[
58
+ 'vc-menu vc-ui fixed inset-auto m-0 max-h-[min(70vh,560px)] w-max min-w-52 max-w-[calc(100vw-24px)] overflow-y-auto rounded border p-1 text-[13px] shadow-lg',
59
+ tone === 'dark'
60
+ ? 'border-white/10 bg-vc-ink text-white/90'
61
+ : 'border-vc-line bg-vc-surface text-vc-ink'
62
+ ]}
63
+ >
64
+ {@render children(close)}
65
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Accessible name for the trigger. */
4
+ label: string;
5
+ trigger: Snippet;
6
+ children: Snippet<[close: () => void]>;
7
+ align?: 'start' | 'end';
8
+ tone?: 'light' | 'dark';
9
+ class?: string;
10
+ };
11
+ declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type Menu = ReturnType<typeof Menu>;
13
+ export default Menu;
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Icon from './Icon.svelte';
4
+
5
+ let {
6
+ onselect,
7
+ checked,
8
+ children,
9
+ hint,
10
+ tone = 'light'
11
+ }: {
12
+ onselect: () => void;
13
+ /** Shows a check mark column when not undefined. */
14
+ checked?: boolean;
15
+ children: Snippet;
16
+ hint?: string;
17
+ tone?: 'light' | 'dark';
18
+ } = $props();
19
+ </script>
20
+
21
+ <button
22
+ type="button"
23
+ role="menuitem"
24
+ onclick={onselect}
25
+ class={[
26
+ 'flex w-full items-center gap-2 rounded-[2px] px-2 py-1.5 text-left',
27
+ tone === 'dark' ? 'hover:bg-white/10 focus-visible:bg-white/10' : 'hover:bg-vc-wash focus-visible:bg-vc-wash',
28
+ 'outline-none'
29
+ ]}
30
+ >
31
+ {#if checked !== undefined}
32
+ <span class="w-4 shrink-0 text-vc-accent">
33
+ {#if checked}<Icon name="check" size={14} />{/if}
34
+ </span>
35
+ {/if}
36
+ <span class="min-w-0 flex-1 truncate">{@render children()}</span>
37
+ {#if hint}<span class="shrink-0 opacity-50">{hint}</span>{/if}
38
+ </button>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ onselect: () => void;
4
+ /** Shows a check mark column when not undefined. */
5
+ checked?: boolean;
6
+ children: Snippet;
7
+ hint?: string;
8
+ tone?: 'light' | 'dark';
9
+ };
10
+ declare const MenuItem: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type MenuItem = ReturnType<typeof MenuItem>;
12
+ export default MenuItem;