@joewinke/jatui 0.1.22 → 0.1.24

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 (124) hide show
  1. package/CLAUDE.md +244 -0
  2. package/package.json +23 -10
  3. package/src/lib/actions/collapse.ts +0 -134
  4. package/src/lib/actions/railNav.ts +0 -473
  5. package/src/lib/components/AnnotationLayer.svelte +0 -108
  6. package/src/lib/components/AnnotationPanel.svelte +0 -319
  7. package/src/lib/components/AudioWaveform.svelte +0 -698
  8. package/src/lib/components/AvailabilityModal.svelte +0 -177
  9. package/src/lib/components/AvatarUpload.svelte +0 -234
  10. package/src/lib/components/Badge.svelte +0 -38
  11. package/src/lib/components/BookingForm.svelte +0 -278
  12. package/src/lib/components/BurndownChart.svelte +0 -778
  13. package/src/lib/components/Button.svelte +0 -81
  14. package/src/lib/components/CalendarPicker.svelte +0 -284
  15. package/src/lib/components/Card.svelte +0 -67
  16. package/src/lib/components/CharacterCounter.svelte +0 -82
  17. package/src/lib/components/ChipInput.svelte +0 -609
  18. package/src/lib/components/Collapse.svelte +0 -82
  19. package/src/lib/components/ColorSelector.svelte +0 -167
  20. package/src/lib/components/CommentThread.svelte +0 -773
  21. package/src/lib/components/ConfirmDialog.svelte +0 -348
  22. package/src/lib/components/ConfirmModal.svelte +0 -142
  23. package/src/lib/components/ContextMenu.svelte +0 -188
  24. package/src/lib/components/CountdownTimer.svelte +0 -94
  25. package/src/lib/components/DateRangePicker.svelte +0 -194
  26. package/src/lib/components/Drawer.svelte +0 -143
  27. package/src/lib/components/EmojiPicker.svelte +0 -159
  28. package/src/lib/components/EntityPreviewCard.svelte +0 -104
  29. package/src/lib/components/FileDropzone.svelte +0 -493
  30. package/src/lib/components/FilePicker.svelte +0 -488
  31. package/src/lib/components/FileThumbnail.svelte +0 -80
  32. package/src/lib/components/FilterDropdown.svelte +0 -203
  33. package/src/lib/components/FloatingActionBar.svelte +0 -354
  34. package/src/lib/components/GPSTracker.svelte +0 -202
  35. package/src/lib/components/HunkDiffView.svelte +0 -348
  36. package/src/lib/components/ImageLightbox.svelte +0 -274
  37. package/src/lib/components/ImageUpload.svelte +0 -147
  38. package/src/lib/components/InlineEdit.svelte +0 -291
  39. package/src/lib/components/InputDialog.svelte +0 -327
  40. package/src/lib/components/KeyboardShortcutsOverlay.svelte +0 -296
  41. package/src/lib/components/LazyImage.svelte +0 -123
  42. package/src/lib/components/LinkShortener.svelte +0 -274
  43. package/src/lib/components/LoadingSpinner.svelte +0 -106
  44. package/src/lib/components/LocationMap.svelte +0 -186
  45. package/src/lib/components/MapView.svelte +0 -341
  46. package/src/lib/components/MarkupEditor.svelte +0 -485
  47. package/src/lib/components/MarkupOverlay.svelte +0 -55
  48. package/src/lib/components/MediaWorkbench.svelte +0 -871
  49. package/src/lib/components/MilestoneCard.svelte +0 -98
  50. package/src/lib/components/MilestoneTimeline.svelte +0 -91
  51. package/src/lib/components/Modal.svelte +0 -244
  52. package/src/lib/components/PDFViewer.svelte +0 -105
  53. package/src/lib/components/PdfThumbnail.svelte +0 -63
  54. package/src/lib/components/PhoneInput.svelte +0 -212
  55. package/src/lib/components/ResizableDivider.svelte +0 -307
  56. package/src/lib/components/ResizablePanel.svelte +0 -305
  57. package/src/lib/components/SearchDropdown.svelte +0 -421
  58. package/src/lib/components/SelectInput.svelte +0 -237
  59. package/src/lib/components/SidebarUserFooter.svelte +0 -34
  60. package/src/lib/components/SignaturePad.svelte +0 -175
  61. package/src/lib/components/SmartImageEditor.svelte +0 -720
  62. package/src/lib/components/SortDropdown.svelte +0 -156
  63. package/src/lib/components/Sparkline.svelte +0 -116
  64. package/src/lib/components/SpeechForm.svelte +0 -114
  65. package/src/lib/components/StatusBadge.svelte +0 -157
  66. package/src/lib/components/TaskTypeIcon.svelte +0 -54
  67. package/src/lib/components/TextArea.svelte +0 -162
  68. package/src/lib/components/TextInput.svelte +0 -131
  69. package/src/lib/components/ThemeSelector.svelte +0 -206
  70. package/src/lib/components/TimeSlotPicker.svelte +0 -162
  71. package/src/lib/components/UserAvatar.svelte +0 -72
  72. package/src/lib/components/VariablePicker.svelte +0 -170
  73. package/src/lib/components/VoicePlayer.svelte +0 -422
  74. package/src/lib/components/linked-columns/LinkedColumns.svelte +0 -526
  75. package/src/lib/components/markup.ts +0 -287
  76. package/src/lib/components/messaging/Avatar.svelte +0 -81
  77. package/src/lib/components/messaging/ChannelInfoModal.svelte +0 -163
  78. package/src/lib/components/messaging/ChannelList.svelte +0 -107
  79. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +0 -69
  80. package/src/lib/components/messaging/ChannelMembersModal.svelte +0 -182
  81. package/src/lib/components/messaging/CreateChannelModal.svelte +0 -190
  82. package/src/lib/components/messaging/DirectMessageList.svelte +0 -145
  83. package/src/lib/components/messaging/EmojiSelector.svelte +0 -261
  84. package/src/lib/components/messaging/MentionAutocomplete.svelte +0 -193
  85. package/src/lib/components/messaging/MessageAttachment.svelte +0 -273
  86. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +0 -243
  87. package/src/lib/components/messaging/MessageInput.svelte +0 -452
  88. package/src/lib/components/messaging/MessageItem.svelte +0 -341
  89. package/src/lib/components/messaging/MessageThread.svelte +0 -306
  90. package/src/lib/components/messaging/NotificationSettingsModal.svelte +0 -234
  91. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +0 -123
  92. package/src/lib/components/messaging/StartDMModal.svelte +0 -100
  93. package/src/lib/components/messaging/ThreadPanel.svelte +0 -153
  94. package/src/lib/components/pipeline/DESIGN.md +0 -361
  95. package/src/lib/components/pipeline/Pipeline.svelte +0 -391
  96. package/src/lib/components/pipeline/PipelineCard.svelte +0 -94
  97. package/src/lib/components/pipeline/PipelineColumn.svelte +0 -163
  98. package/src/lib/components/replay/ChapterTimeline.svelte +0 -326
  99. package/src/lib/components/session-nav/transcriptModel.ts +0 -352
  100. package/src/lib/data/emojis.ts +0 -1208
  101. package/src/lib/index.ts +0 -405
  102. package/src/lib/stores/confirmDialog.svelte.ts +0 -48
  103. package/src/lib/stores/inputDialog.svelte.ts +0 -51
  104. package/src/lib/styles/rail.css +0 -63
  105. package/src/lib/types/annotation.ts +0 -38
  106. package/src/lib/types/booking.ts +0 -143
  107. package/src/lib/types/comments.ts +0 -97
  108. package/src/lib/types/entityPreview.ts +0 -45
  109. package/src/lib/types/filePicker.ts +0 -16
  110. package/src/lib/types/googleMaps.d.ts +0 -51
  111. package/src/lib/types/maps.ts +0 -43
  112. package/src/lib/types/messaging.ts +0 -459
  113. package/src/lib/types/milestone.ts +0 -22
  114. package/src/lib/types/pipeline.ts +0 -150
  115. package/src/lib/types/smartImageEditor.ts +0 -39
  116. package/src/lib/types/templateVars.ts +0 -36
  117. package/src/lib/utils/currency.ts +0 -20
  118. package/src/lib/utils/daisyuiColors.ts +0 -243
  119. package/src/lib/utils/dateFormatters.ts +0 -196
  120. package/src/lib/utils/googleMapsLoader.ts +0 -84
  121. package/src/lib/utils/mentionParser.ts +0 -188
  122. package/src/lib/utils/phone.ts +0 -80
  123. package/src/lib/utils/phoneFormat.ts +0 -74
  124. package/src/lib/utils/taskUtils.ts +0 -75
@@ -1,473 +0,0 @@
1
- /**
2
- * Keyboard-rail navigation primitive — action + composable.
3
- *
4
- * A drawer / form is a vertical sequence of "rungs". Focus starts in the first
5
- * field. Up / Down (and j / k when NOT typing) step between rungs. Left / Right
6
- * acts on the focused rung (cycle a value, switch a tab, open a picker). Escape
7
- * pops focus out of a field back to the container, then fires `onEscape`.
8
- *
9
- * Rungs are ordinary HTML elements tagged with a `data-rail-id` attribute (or
10
- * whatever attribute `attr` specifies). The consumer supplies an `order` array
11
- * of ids that defines traversal; ids whose element is not currently rendered
12
- * in the container are skipped at move time.
13
- *
14
- * ─── CSS contract ─────────────────────────────────────────────────────────────
15
- *
16
- * The controller applies two classes to rung host elements. Style them in your
17
- * stylesheet (or in a parent scope):
18
- *
19
- * .rail-focus — persistent: applied to the currently active rung host. Use
20
- * `:focus-visible` / `:has(:focus-visible)` inside the rule so
21
- * only keyboard-driven focus shows a visible ring.
22
- *
23
- * .rail-cycled — transient (~280ms): applied when Left / Right changes a
24
- * rung's value via `rail.bump(host)`. Use for a brief pulse
25
- * that confirms the value changed.
26
- *
27
- * ─── Usage — action form (simplest) ──────────────────────────────────────────
28
- *
29
- * <div use:railNav={{
30
- * order: () => ['title', 'type', 'priority'],
31
- * onAction: (id, dir, host) => cycleField(id, dir),
32
- * onEscape: () => closeDrawer(),
33
- * }}>
34
- * <input data-rail-id="title" />
35
- * <div data-rail-id="type" tabindex="-1">…</div>
36
- * <div data-rail-id="priority" tabindex="-1">…</div>
37
- * </div>
38
- *
39
- * ─── Usage — composable form ──────────────────────────────────────────────────
40
- *
41
- * const rail = createRailNav({
42
- * container: panelEl,
43
- * order: () => currentOrder,
44
- * onAction: (id, dir, host) => { rail.bump(host); cycleField(id, dir); },
45
- * onEscape: () => closeDrawer(),
46
- * });
47
- *
48
- * // Wire both listeners on the container:
49
- * panelEl.addEventListener('keydown', (e) => rail.handleKeydown(e));
50
- * panelEl.addEventListener('focusin', (e) => rail.handleFocusin(e));
51
- *
52
- * ─── How to wire a drawer to railNav ──────────────────────────────────────────
53
- *
54
- * 1. Tag each rung host with `data-rail-id`:
55
- * <div data-rail-id="type" tabindex="-1">…</div>
56
- *
57
- * 2. Apply the action (or composable) to the drawer panel:
58
- * <div bind:this={panel} use:railNav={{ order, onAction, onEscape }} …>
59
- *
60
- * 3. Handle Left/Right in `onAction` and call `rail.bump(host)` on change:
61
- * onAction: (id, dir, host) => { formData[id] = cycle(opts[id], …, dir); rail.bump(host); }
62
- */
63
-
64
- import type { Action } from 'svelte/action';
65
-
66
- // ─── Public types ──────────────────────────────────────────────────────────────
67
-
68
- export interface RailNavOptions {
69
- /**
70
- * Rung id order, top → bottom. May be a plain array or a getter (called on
71
- * every navigation event so reactive lists stay in sync).
72
- */
73
- order: string[] | (() => string[]);
74
-
75
- /**
76
- * Called when Left / Right is pressed on a non-text rung.
77
- * `dir` is `1` for Right and `-1` for Left.
78
- * Call `rail.bump(host)` inside this handler if you want the pulse effect.
79
- */
80
- onAction?: (rungId: string, dir: 1 | -1, host: HTMLElement) => void;
81
-
82
- /**
83
- * Called when Escape is pressed while focus is already on (or has been
84
- * popped back to) the container itself. Typically closes the drawer / modal.
85
- */
86
- onEscape?: () => void;
87
-
88
- /**
89
- * Optional: when a child popover currently owns keyboard focus, return `true`
90
- * here to suppress rail navigation. Escape will call `onPopoverEscape` first.
91
- */
92
- isPopoverOpen?: () => boolean;
93
-
94
- /** Called on Escape when `isPopoverOpen()` returns true. */
95
- onPopoverEscape?: () => void;
96
-
97
- /**
98
- * When `true` (default), keystrokes on non-text rungs are stopped from
99
- * bubbling to window-level listeners behind a modal.
100
- */
101
- trap?: boolean;
102
-
103
- /**
104
- * Attribute used as the rung identifier. Default: `'data-rail-id'`.
105
- * The controller looks for elements matching `[<attr>="<id>"]`.
106
- */
107
- attr?: string;
108
-
109
- /**
110
- * The container element to search for rung hosts.
111
- *
112
- * Required for `createRailNav`. The `railNav` action fills this automatically
113
- * from the action's `node`.
114
- */
115
- container?: HTMLElement;
116
- }
117
-
118
- export interface RailNavController {
119
- /**
120
- * Feed a keydown event. Returns `true` if the event was consumed.
121
- * Attach to the container's `keydown` listener (or `onkeydown` in Svelte).
122
- */
123
- handleKeydown(event: KeyboardEvent): boolean;
124
-
125
- /**
126
- * Feed a focusin event so the controller can track which rung the user
127
- * Tabbed into. Attach to the container's `focusin` listener.
128
- */
129
- handleFocusin(event: FocusEvent): void;
130
-
131
- /** Step one rung in direction `1` (down) or `-1` (up). Wraps around. */
132
- moveRung(dir: 1 | -1): void;
133
-
134
- /** Focus the rung with the given id. No-op if the element is not rendered. */
135
- focus(id: string): void;
136
-
137
- /**
138
- * Re-derive the current rung list after a conditional rung appears or
139
- * disappears. The action calls this automatically in its `update` hook.
140
- * Call manually in the composable form after reactive state changes.
141
- */
142
- refresh(): void;
143
-
144
- /** The id of the last rung that received focus, or `''` if none. */
145
- currentId(): string;
146
-
147
- /**
148
- * Applies the transient `rail-cycled` class to `host` for ~280ms.
149
- * Call this inside `onAction` when a Left / Right key changes a value.
150
- */
151
- bump(host: HTMLElement | null): void;
152
- }
153
-
154
- // ─── Exported helpers ──────────────────────────────────────────────────────────
155
-
156
- /**
157
- * Cycle through `list`, returning the item `dir` steps from `current`.
158
- * Wraps around. Returns `current` unchanged when the list is empty.
159
- *
160
- * @example
161
- * cycle(['bug', 'feature', 'task'], 'feature', 1) // → 'task'
162
- * cycle(['bug', 'feature', 'task'], 'bug', -1) // → 'task'
163
- * cycle(['a', 'b'], 'z', 1) // → 'a' (not found → start)
164
- */
165
- export function cycle<T>(list: readonly T[], current: T, dir: 1 | -1): T {
166
- if (list.length === 0) return current;
167
- const i = list.indexOf(current);
168
- // When `current` is not in the list, treat it as if we're at position -1
169
- // so that `dir=1` lands at index 0 (the first item) and `dir=-1` lands at
170
- // the last item — matching the "not found → start from the beginning" idiom.
171
- const base = i === -1 ? -1 : i;
172
- return list[(((base + dir) % list.length) + list.length) % list.length];
173
- }
174
-
175
- // ─── Internals ─────────────────────────────────────────────────────────────────
176
-
177
- const BUMP_DURATION_MS = 280;
178
- const RAIL_FOCUS_CLASS = 'rail-focus';
179
- const RAIL_CYCLED_CLASS = 'rail-cycled';
180
-
181
- interface OptsRef {
182
- current: RailNavOptions;
183
- }
184
-
185
- function resolveOrder(opts: RailNavOptions): string[] {
186
- return typeof opts.order === 'function' ? opts.order() : opts.order;
187
- }
188
-
189
- function resolveAttr(opts: RailNavOptions): string {
190
- return opts.attr ?? 'data-rail-id';
191
- }
192
-
193
- /** Return all currently-rendered rung hosts in the rail order. */
194
- function resolveHosts(optsRef: OptsRef): HTMLElement[] {
195
- const { container } = optsRef.current;
196
- if (!container) return [];
197
- const attr = resolveAttr(optsRef.current);
198
- const order = resolveOrder(optsRef.current);
199
- return order
200
- .map((id) => container.querySelector<HTMLElement>(`[${attr}="${id}"]`))
201
- .filter((el): el is HTMLElement => !!el);
202
- }
203
-
204
- /**
205
- * Focus the most appropriate control inside a rung host:
206
- *
207
- * 1. If the host itself is directly focusable (input / select / textarea /
208
- * button / [contenteditable] / [tabindex]), focus it.
209
- * 2. Otherwise search for a real field before a trigger button. We use an
210
- * explicit selector for real fields first because `querySelector` resolves
211
- * by document order — a label's `<button>` can appear before the field it
212
- * accompanies (e.g. a voice-mic button before a contenteditable).
213
- * 3. Fall back to any `.sd-trigger,button` (SearchDropdown trigger etc.).
214
- * 4. Last resort: focus the host itself (requires tabindex on the host).
215
- */
216
- function focusRailHost(host: HTMLElement): void {
217
- if (
218
- host.matches('input,select,textarea,button,[contenteditable="true"],[tabindex]')
219
- ) {
220
- host.focus();
221
- return;
222
- }
223
- const inner =
224
- host.querySelector<HTMLElement>('[contenteditable="true"],input,select,textarea') ??
225
- host.querySelector<HTMLElement>('.sd-trigger,button');
226
- (inner ?? host).focus();
227
- }
228
-
229
- function makeController(optsRef: OptsRef): RailNavController {
230
- let _currentRailId = '';
231
- let _lastFocusedHost: HTMLElement | null = null;
232
-
233
- function applyFocusClass(host: HTMLElement | null): void {
234
- if (_lastFocusedHost && _lastFocusedHost !== host) {
235
- _lastFocusedHost.classList.remove(RAIL_FOCUS_CLASS);
236
- }
237
- if (host) host.classList.add(RAIL_FOCUS_CLASS);
238
- _lastFocusedHost = host;
239
- }
240
-
241
- function bump(host: HTMLElement | null): void {
242
- if (!host) return;
243
- host.classList.remove(RAIL_CYCLED_CLASS);
244
- void host.offsetWidth; // reflow so the animation restarts on rapid presses
245
- host.classList.add(RAIL_CYCLED_CLASS);
246
- setTimeout(() => host.classList.remove(RAIL_CYCLED_CLASS), BUMP_DURATION_MS);
247
- }
248
-
249
- function focus(id: string): void {
250
- const { container } = optsRef.current;
251
- if (!container) return;
252
- const attr = resolveAttr(optsRef.current);
253
- const host = container.querySelector<HTMLElement>(`[${attr}="${id}"]`);
254
- if (host) {
255
- focusRailHost(host);
256
- applyFocusClass(host);
257
- _currentRailId = id;
258
- } else {
259
- container.focus();
260
- }
261
- }
262
-
263
- function moveRung(dir: 1 | -1): void {
264
- const hosts = resolveHosts(optsRef);
265
- if (hosts.length === 0) return;
266
- const attr = resolveAttr(optsRef.current);
267
- const ids = hosts.map((h) => h.getAttribute(attr)!);
268
-
269
- // Anchor on the actually-focused rung first. Fall back to the last tracked
270
- // rung (covers focus parked on the container after an Escape pop-out).
271
- const active = document.activeElement as HTMLElement | null;
272
- const activeFocused = (
273
- active?.closest<HTMLElement>(`[${attr}]`)
274
- )?.getAttribute(attr);
275
- const fromId =
276
- activeFocused && ids.includes(activeFocused) ? activeFocused : _currentRailId;
277
-
278
- let pos = fromId ? ids.indexOf(fromId) : -1;
279
- if (pos === -1) pos = dir === 1 ? -1 : hosts.length;
280
- const next = (((pos + dir) % hosts.length) + hosts.length) % hosts.length;
281
- focusRailHost(hosts[next]);
282
- applyFocusClass(hosts[next]);
283
- _currentRailId = ids[next];
284
- }
285
-
286
- function refresh(): void {
287
- // Re-derive hosts; re-apply focus class if the current rung is still rendered
288
- // (DOM may have been reconstructed by Svelte's reactive updates).
289
- const hosts = resolveHosts(optsRef);
290
- const attr = resolveAttr(optsRef.current);
291
- for (const h of hosts) {
292
- if (h.getAttribute(attr) === _currentRailId) {
293
- applyFocusClass(h);
294
- return;
295
- }
296
- }
297
- }
298
-
299
- function handleFocusin(event: FocusEvent): void {
300
- const attr = resolveAttr(optsRef.current);
301
- const order = resolveOrder(optsRef.current);
302
- const host = (event.target as HTMLElement | null)?.closest<HTMLElement>(
303
- `[${attr}]`
304
- );
305
- const id = host?.getAttribute(attr);
306
- // Only track rungs that are in the rail order. Tabbing into an off-rail
307
- // control (e.g. a description textarea) leaves the last real rung intact
308
- // so Up / Down can resume from it after the user tabs back out.
309
- if (id && order.includes(id)) {
310
- _currentRailId = id;
311
- applyFocusClass(host!);
312
- }
313
- }
314
-
315
- function handleKeydown(event: KeyboardEvent): boolean {
316
- const opts = optsRef.current;
317
- const el = document.activeElement as HTMLElement | null;
318
- const tag = el?.tagName;
319
- const isTextField =
320
- !!el &&
321
- (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true);
322
- const isMultiline =
323
- !!el && (tag === 'TEXTAREA' || el.isContentEditable === true);
324
- const hasMod = event.metaKey || event.ctrlKey || event.altKey;
325
-
326
- // Modal trap — stop non-text keystrokes from bubbling to window-level page
327
- // listeners (e.g., a listNav attached to `window`). Text-field keystrokes
328
- // propagate normally so typing in the drawer doesn't interfere.
329
- const trap = opts.trap !== false;
330
- if (trap && !isTextField) event.stopPropagation();
331
-
332
- // Escape cascade:
333
- // 1. popover open → close popover (e.g. project dropdown)
334
- // 2. focus inside a descendant control → pop back to container
335
- // 3. focus already on the container (or just popped) → call onEscape
336
- if (event.key === 'Escape' && !hasMod) {
337
- const popoverOpen = opts.isPopoverOpen?.() ?? false;
338
- if (popoverOpen) {
339
- event.preventDefault();
340
- opts.onPopoverEscape?.();
341
- return true;
342
- }
343
- const { container } = opts;
344
- if (container && el && el !== container && container.contains(el)) {
345
- event.preventDefault();
346
- container.focus();
347
- return true;
348
- }
349
- event.preventDefault();
350
- opts.onEscape?.();
351
- return true;
352
- }
353
-
354
- // Rail motion — suppressed when a popover is open or a modifier is held.
355
- const popoverOpen = opts.isPopoverOpen?.() ?? false;
356
- if (!hasMod && !popoverOpen) {
357
- // Vertical: ArrowDown / j step down; ArrowUp / k step up.
358
- // j / k are blocked in ANY text field; arrows are blocked only inside
359
- // multiline fields so the caret can still move line-by-line.
360
- const goDown = event.key === 'ArrowDown' || event.key === 'j';
361
- const goUp = event.key === 'ArrowUp' || event.key === 'k';
362
- if (goDown || goUp) {
363
- const isVimKey = event.key === 'j' || event.key === 'k';
364
- const blocked = isVimKey ? isTextField : isMultiline;
365
- if (!blocked) {
366
- event.preventDefault();
367
- moveRung(goDown ? 1 : -1);
368
- return true;
369
- }
370
- }
371
-
372
- // Horizontal: ArrowLeft / ArrowRight dispatch to `onAction`.
373
- // Skipped inside text fields so caret-left / caret-right keep working.
374
- if (
375
- (event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
376
- !isTextField
377
- ) {
378
- event.preventDefault();
379
- const attr = resolveAttr(opts);
380
- // Prefer the element that actually has DOM focus, then fall back to
381
- // the last tracked rung (focus parked on the container after pop-out).
382
- const focusedHost =
383
- (el?.closest<HTMLElement>(`[${attr}]`)) ??
384
- (opts.container && _currentRailId
385
- ? opts.container.querySelector<HTMLElement>(
386
- `[${attr}="${_currentRailId}"]`
387
- )
388
- : null);
389
- if (focusedHost && opts.onAction) {
390
- const rungId = focusedHost.getAttribute(attr) ?? '';
391
- opts.onAction(
392
- rungId,
393
- event.key === 'ArrowRight' ? 1 : -1,
394
- focusedHost
395
- );
396
- }
397
- return true;
398
- }
399
- }
400
-
401
- return false;
402
- }
403
-
404
- return {
405
- handleKeydown,
406
- handleFocusin,
407
- moveRung,
408
- focus,
409
- refresh,
410
- currentId: () => _currentRailId,
411
- bump,
412
- };
413
- }
414
-
415
- // ─── Composable ────────────────────────────────────────────────────────────────
416
-
417
- /**
418
- * Imperative composable — use when you already own keydown / focusin handlers
419
- * and want `railNav` to decide whether to consume the event.
420
- *
421
- * You **must** supply `options.container` and wire both events:
422
- *
423
- * const rail = createRailNav({ container: panel, order, onAction, onEscape });
424
- * panel.addEventListener('keydown', (e) => rail.handleKeydown(e));
425
- * panel.addEventListener('focusin', (e) => rail.handleFocusin(e));
426
- */
427
- export function createRailNav(options: RailNavOptions): RailNavController {
428
- const ref: OptsRef = { current: options };
429
- return makeController(ref);
430
- }
431
-
432
- // ─── Action ────────────────────────────────────────────────────────────────────
433
-
434
- /**
435
- * Svelte action — attaches `keydown` and `focusin` listeners to `node` and
436
- * handles rung stepping, horizontal action dispatch, and Escape cascade.
437
- *
438
- * @example
439
- * <div use:railNav={{ order, onAction, onEscape }}>
440
- * <input data-rail-id="title" />
441
- * <div data-rail-id="type" tabindex="-1">…</div>
442
- * <div data-rail-id="priority" tabindex="-1">…</div>
443
- * </div>
444
- */
445
- export const railNav: Action<HTMLElement, RailNavOptions | undefined> = (
446
- node,
447
- initial = { order: [] }
448
- ) => {
449
- const ref: OptsRef = { current: { ...initial, container: node } };
450
- const controller = makeController(ref);
451
-
452
- function onKeydown(e: KeyboardEvent) {
453
- controller.handleKeydown(e);
454
- }
455
-
456
- function onFocusin(e: FocusEvent) {
457
- controller.handleFocusin(e);
458
- }
459
-
460
- node.addEventListener('keydown', onKeydown);
461
- node.addEventListener('focusin', onFocusin);
462
-
463
- return {
464
- update(next: RailNavOptions | undefined = { order: [] }) {
465
- ref.current = { ...next, container: node };
466
- controller.refresh();
467
- },
468
- destroy() {
469
- node.removeEventListener('keydown', onKeydown);
470
- node.removeEventListener('focusin', onFocusin);
471
- },
472
- };
473
- };
@@ -1,108 +0,0 @@
1
- <script lang="ts">
2
- import type { Annotation, AnnotationThread } from '../types/annotation'
3
-
4
- interface Props {
5
- annotations: Annotation[]
6
- activeAnnotationId?: string | null
7
- annotationMode?: boolean
8
- showResolved?: boolean
9
- onAnnotationClick?: (annotation: Annotation) => void
10
- onImageClick?: (x: number, y: number) => void
11
- }
12
-
13
- let {
14
- annotations = [],
15
- activeAnnotationId = null,
16
- annotationMode = false,
17
- showResolved = false,
18
- onAnnotationClick,
19
- onImageClick,
20
- }: Props = $props()
21
-
22
- // Group into threads (only show root pins)
23
- const threads = $derived.by(() => {
24
- const roots = annotations.filter((a) => !a.parent_annotation_id)
25
- return roots
26
- .filter((r) => showResolved || !r.resolved)
27
- .map((root) => ({
28
- root,
29
- replies: annotations.filter((a) => a.parent_annotation_id === root.id),
30
- }))
31
- })
32
-
33
- function handleOverlayClick(e: MouseEvent) {
34
- if (!annotationMode || !onImageClick) return
35
- const target = e.currentTarget as HTMLElement
36
- const rect = target.getBoundingClientRect()
37
- const x = ((e.clientX - rect.left) / rect.width) * 100
38
- const y = ((e.clientY - rect.top) / rect.height) * 100
39
- onImageClick(Math.round(x * 1000) / 1000, Math.round(y * 1000) / 1000)
40
- }
41
- </script>
42
-
43
- <!-- svelte-ignore a11y_click_events_have_key_events -->
44
- <!-- svelte-ignore a11y_no_static_element_interactions -->
45
- <div
46
- class="absolute inset-0 z-10"
47
- class:cursor-crosshair={annotationMode}
48
- class:pointer-events-none={!annotationMode && threads.length === 0}
49
- onclick={handleOverlayClick}
50
- >
51
- {#each threads as thread (thread.root.id)}
52
- {@const isActive = activeAnnotationId === thread.root.id}
53
- {@const replyCount = thread.replies.length}
54
- <!-- svelte-ignore a11y_click_events_have_key_events -->
55
- <!-- svelte-ignore a11y_no_static_element_interactions -->
56
- <div
57
- class="absolute pointer-events-auto"
58
- style="left: {thread.root.x_percent}%; top: {thread.root.y_percent}%; transform: translate(-50%, -100%);"
59
- >
60
- <button
61
- class="relative group flex flex-col items-center"
62
- onclick={(e) => { e.stopPropagation(); onAnnotationClick?.(thread.root) }}
63
- title={thread.root.content.slice(0, 80)}
64
- >
65
- <!-- Pin icon -->
66
- <div
67
- class="w-7 h-7 rounded-full flex items-center justify-center shadow-lg border-2 transition-all
68
- {isActive
69
- ? 'bg-primary border-primary text-primary-content scale-125'
70
- : thread.root.resolved
71
- ? 'bg-success/80 border-success/60 text-success-content'
72
- : 'bg-warning border-warning/80 text-warning-content hover:scale-110'}"
73
- >
74
- {#if thread.root.resolved}
75
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
76
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
77
- </svg>
78
- {:else}
79
- <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
80
- <path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" />
81
- </svg>
82
- {/if}
83
- </div>
84
-
85
- <!-- Reply count badge -->
86
- {#if replyCount > 0}
87
- <span class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-base-content text-base-100 text-[10px] font-bold flex items-center justify-center">
88
- {replyCount}
89
- </span>
90
- {/if}
91
-
92
- <!-- Pin tail -->
93
- <div
94
- class="w-0.5 h-2 -mt-0.5 {isActive ? 'bg-primary' : thread.root.resolved ? 'bg-success/60' : 'bg-warning/80'}"
95
- ></div>
96
- </button>
97
- </div>
98
- {/each}
99
-
100
- <!-- Placement indicator when in annotation mode -->
101
- {#if annotationMode}
102
- <div class="absolute inset-0 border-2 border-dashed border-primary/30 rounded pointer-events-none">
103
- <div class="absolute top-2 left-1/2 -translate-x-1/2 bg-primary/90 text-primary-content text-xs px-3 py-1 rounded-full shadow whitespace-nowrap">
104
- Click to place annotation
105
- </div>
106
- </div>
107
- {/if}
108
- </div>