@joewinke/jatui 0.1.23 → 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.
- package/CLAUDE.md +244 -0
- package/package.json +23 -10
- package/src/lib/actions/collapse.ts +0 -136
- package/src/lib/actions/railNav.ts +0 -473
- package/src/lib/components/AnnotationLayer.svelte +0 -108
- package/src/lib/components/AnnotationPanel.svelte +0 -319
- package/src/lib/components/AudioWaveform.svelte +0 -698
- package/src/lib/components/AvailabilityModal.svelte +0 -177
- package/src/lib/components/AvatarUpload.svelte +0 -234
- package/src/lib/components/Badge.svelte +0 -38
- package/src/lib/components/BookingForm.svelte +0 -278
- package/src/lib/components/BurndownChart.svelte +0 -778
- package/src/lib/components/Button.svelte +0 -81
- package/src/lib/components/CalendarPicker.svelte +0 -284
- package/src/lib/components/Card.svelte +0 -67
- package/src/lib/components/CharacterCounter.svelte +0 -82
- package/src/lib/components/ChipInput.svelte +0 -609
- package/src/lib/components/Collapse.svelte +0 -82
- package/src/lib/components/ColorSelector.svelte +0 -167
- package/src/lib/components/CommentThread.svelte +0 -773
- package/src/lib/components/ConfirmDialog.svelte +0 -348
- package/src/lib/components/ConfirmModal.svelte +0 -142
- package/src/lib/components/ContextMenu.svelte +0 -188
- package/src/lib/components/CountdownTimer.svelte +0 -94
- package/src/lib/components/DateRangePicker.svelte +0 -194
- package/src/lib/components/Drawer.svelte +0 -143
- package/src/lib/components/EmojiPicker.svelte +0 -159
- package/src/lib/components/EntityPreviewCard.svelte +0 -104
- package/src/lib/components/FileDropzone.svelte +0 -493
- package/src/lib/components/FilePicker.svelte +0 -488
- package/src/lib/components/FileThumbnail.svelte +0 -80
- package/src/lib/components/FilterDropdown.svelte +0 -203
- package/src/lib/components/FloatingActionBar.svelte +0 -354
- package/src/lib/components/GPSTracker.svelte +0 -202
- package/src/lib/components/HunkDiffView.svelte +0 -348
- package/src/lib/components/ImageLightbox.svelte +0 -274
- package/src/lib/components/ImageUpload.svelte +0 -147
- package/src/lib/components/InlineEdit.svelte +0 -291
- package/src/lib/components/InputDialog.svelte +0 -327
- package/src/lib/components/KeyboardShortcutsOverlay.svelte +0 -296
- package/src/lib/components/LazyImage.svelte +0 -123
- package/src/lib/components/LinkShortener.svelte +0 -274
- package/src/lib/components/LoadingSpinner.svelte +0 -106
- package/src/lib/components/LocationMap.svelte +0 -186
- package/src/lib/components/MapView.svelte +0 -341
- package/src/lib/components/MarkupEditor.svelte +0 -485
- package/src/lib/components/MarkupOverlay.svelte +0 -55
- package/src/lib/components/MediaWorkbench.svelte +0 -871
- package/src/lib/components/MilestoneCard.svelte +0 -98
- package/src/lib/components/MilestoneTimeline.svelte +0 -91
- package/src/lib/components/Modal.svelte +0 -244
- package/src/lib/components/PDFViewer.svelte +0 -105
- package/src/lib/components/PdfThumbnail.svelte +0 -63
- package/src/lib/components/PhoneInput.svelte +0 -212
- package/src/lib/components/ResizableDivider.svelte +0 -307
- package/src/lib/components/ResizablePanel.svelte +0 -305
- package/src/lib/components/SearchDropdown.svelte +0 -421
- package/src/lib/components/SelectInput.svelte +0 -237
- package/src/lib/components/SidebarUserFooter.svelte +0 -34
- package/src/lib/components/SignaturePad.svelte +0 -175
- package/src/lib/components/SmartImageEditor.svelte +0 -720
- package/src/lib/components/SortDropdown.svelte +0 -156
- package/src/lib/components/Sparkline.svelte +0 -116
- package/src/lib/components/SpeechForm.svelte +0 -114
- package/src/lib/components/StatusBadge.svelte +0 -157
- package/src/lib/components/TaskTypeIcon.svelte +0 -54
- package/src/lib/components/TextArea.svelte +0 -162
- package/src/lib/components/TextInput.svelte +0 -131
- package/src/lib/components/ThemeSelector.svelte +0 -206
- package/src/lib/components/TimeSlotPicker.svelte +0 -162
- package/src/lib/components/UserAvatar.svelte +0 -72
- package/src/lib/components/VariablePicker.svelte +0 -170
- package/src/lib/components/VoicePlayer.svelte +0 -422
- package/src/lib/components/linked-columns/LinkedColumns.svelte +0 -526
- package/src/lib/components/markup.ts +0 -287
- package/src/lib/components/messaging/Avatar.svelte +0 -81
- package/src/lib/components/messaging/ChannelInfoModal.svelte +0 -163
- package/src/lib/components/messaging/ChannelList.svelte +0 -107
- package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +0 -69
- package/src/lib/components/messaging/ChannelMembersModal.svelte +0 -182
- package/src/lib/components/messaging/CreateChannelModal.svelte +0 -190
- package/src/lib/components/messaging/DirectMessageList.svelte +0 -145
- package/src/lib/components/messaging/EmojiSelector.svelte +0 -261
- package/src/lib/components/messaging/MentionAutocomplete.svelte +0 -193
- package/src/lib/components/messaging/MessageAttachment.svelte +0 -273
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +0 -243
- package/src/lib/components/messaging/MessageInput.svelte +0 -452
- package/src/lib/components/messaging/MessageItem.svelte +0 -341
- package/src/lib/components/messaging/MessageThread.svelte +0 -306
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +0 -234
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +0 -123
- package/src/lib/components/messaging/StartDMModal.svelte +0 -100
- package/src/lib/components/messaging/ThreadPanel.svelte +0 -153
- package/src/lib/components/pipeline/DESIGN.md +0 -361
- package/src/lib/components/pipeline/Pipeline.svelte +0 -391
- package/src/lib/components/pipeline/PipelineCard.svelte +0 -94
- package/src/lib/components/pipeline/PipelineColumn.svelte +0 -163
- package/src/lib/components/replay/ChapterTimeline.svelte +0 -326
- package/src/lib/components/session-nav/transcriptModel.ts +0 -352
- package/src/lib/data/emojis.ts +0 -1208
- package/src/lib/index.ts +0 -405
- package/src/lib/stores/confirmDialog.svelte.ts +0 -48
- package/src/lib/stores/inputDialog.svelte.ts +0 -51
- package/src/lib/styles/rail.css +0 -63
- package/src/lib/types/annotation.ts +0 -38
- package/src/lib/types/booking.ts +0 -143
- package/src/lib/types/comments.ts +0 -97
- package/src/lib/types/entityPreview.ts +0 -45
- package/src/lib/types/filePicker.ts +0 -16
- package/src/lib/types/googleMaps.d.ts +0 -51
- package/src/lib/types/maps.ts +0 -43
- package/src/lib/types/messaging.ts +0 -459
- package/src/lib/types/milestone.ts +0 -22
- package/src/lib/types/pipeline.ts +0 -150
- package/src/lib/types/smartImageEditor.ts +0 -39
- package/src/lib/types/templateVars.ts +0 -36
- package/src/lib/utils/currency.ts +0 -20
- package/src/lib/utils/daisyuiColors.ts +0 -243
- package/src/lib/utils/dateFormatters.ts +0 -196
- package/src/lib/utils/googleMapsLoader.ts +0 -84
- package/src/lib/utils/mentionParser.ts +0 -188
- package/src/lib/utils/phone.ts +0 -80
- package/src/lib/utils/phoneFormat.ts +0 -74
- 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>
|