@joewinke/jatui 0.1.11 → 0.1.19

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 (90) hide show
  1. package/README.md +123 -0
  2. package/package.json +2 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +21 -15
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +59 -19
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/HunkDiffView.svelte +348 -0
  29. package/src/lib/components/ImageLightbox.svelte +274 -0
  30. package/src/lib/components/ImageUpload.svelte +58 -9
  31. package/src/lib/components/InlineEdit.svelte +15 -9
  32. package/src/lib/components/InputDialog.svelte +327 -0
  33. package/src/lib/components/LazyImage.svelte +1 -0
  34. package/src/lib/components/LinkShortener.svelte +1 -1
  35. package/src/lib/components/LoadingSpinner.svelte +6 -2
  36. package/src/lib/components/MarkupEditor.svelte +485 -0
  37. package/src/lib/components/MarkupOverlay.svelte +55 -0
  38. package/src/lib/components/MediaWorkbench.svelte +871 -0
  39. package/src/lib/components/MilestoneCard.svelte +1 -1
  40. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  41. package/src/lib/components/Modal.svelte +39 -4
  42. package/src/lib/components/PDFViewer.svelte +105 -0
  43. package/src/lib/components/PdfThumbnail.svelte +3 -1
  44. package/src/lib/components/PhoneInput.svelte +1 -1
  45. package/src/lib/components/ResizablePanel.svelte +4 -4
  46. package/src/lib/components/SearchDropdown.svelte +26 -13
  47. package/src/lib/components/SelectInput.svelte +26 -4
  48. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  49. package/src/lib/components/SignaturePad.svelte +8 -4
  50. package/src/lib/components/SmartImageEditor.svelte +720 -0
  51. package/src/lib/components/SortDropdown.svelte +9 -3
  52. package/src/lib/components/Sparkline.svelte +9 -0
  53. package/src/lib/components/StatusBadge.svelte +20 -18
  54. package/src/lib/components/TextArea.svelte +24 -5
  55. package/src/lib/components/TextInput.svelte +29 -6
  56. package/src/lib/components/ThemeSelector.svelte +15 -4
  57. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  58. package/src/lib/components/UserAvatar.svelte +14 -1
  59. package/src/lib/components/VariablePicker.svelte +170 -0
  60. package/src/lib/components/VoicePlayer.svelte +4 -3
  61. package/src/lib/components/markup.ts +287 -0
  62. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  63. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  64. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  65. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  66. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  67. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  68. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  69. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  70. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  71. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  72. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  73. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  74. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  75. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  76. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  77. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  78. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  79. package/src/lib/index.ts +91 -0
  80. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  81. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  82. package/src/lib/styles/rail.css +63 -0
  83. package/src/lib/types/annotation.ts +38 -0
  84. package/src/lib/types/comments.ts +97 -0
  85. package/src/lib/types/entityPreview.ts +45 -0
  86. package/src/lib/types/filePicker.ts +2 -0
  87. package/src/lib/types/smartImageEditor.ts +39 -0
  88. package/src/lib/types/templateVars.ts +36 -0
  89. package/src/lib/utils/dateFormatters.ts +12 -10
  90. package/src/lib/utils/taskUtils.ts +21 -7
@@ -0,0 +1,348 @@
1
+ <script lang="ts">
2
+ import { fly, fade } from 'svelte/transition';
3
+ import { cubicOut } from 'svelte/easing';
4
+ import { tick } from 'svelte';
5
+ import { getPending, confirmDialog, cancelDialog } from '../stores/confirmDialog.svelte';
6
+
7
+ const pending = $derived(getPending());
8
+ const opts = $derived(pending?.options ?? null);
9
+ const visible = $derived(pending !== null);
10
+
11
+ // Countdown state
12
+ let remaining = $state(0);
13
+ let total = $state(0);
14
+ let intervalId: ReturnType<typeof setInterval> | null = null;
15
+ let cancelRef = $state<HTMLButtonElement | null>(null);
16
+
17
+ $effect(() => {
18
+ if (!visible || !opts) {
19
+ if (intervalId) { clearInterval(intervalId); intervalId = null; }
20
+ remaining = 0;
21
+ total = 0;
22
+ return;
23
+ }
24
+
25
+ const ms = opts.timeoutMs ?? 8000;
26
+ if (ms > 0) {
27
+ total = ms;
28
+ remaining = ms;
29
+ intervalId = setInterval(() => {
30
+ remaining -= 60;
31
+ if (remaining <= 0) {
32
+ remaining = 0;
33
+ clearInterval(intervalId!);
34
+ intervalId = null;
35
+ cancelDialog();
36
+ }
37
+ }, 60);
38
+ } else {
39
+ total = 0;
40
+ remaining = 0;
41
+ }
42
+
43
+ // Focus the cancel button (safe default for destructive actions)
44
+ tick().then(() => cancelRef?.focus());
45
+
46
+ return () => {
47
+ if (intervalId) { clearInterval(intervalId); intervalId = null; }
48
+ };
49
+ });
50
+
51
+ const pct = $derived(total > 0 ? (remaining / total) * 100 : 0);
52
+ const secs = $derived(total > 0 ? Math.ceil(remaining / 1000) : 0);
53
+ const isDanger = $derived(opts?.danger !== false); // default true
54
+
55
+ function handleKeydown(e: KeyboardEvent) {
56
+ if (!visible) return;
57
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelDialog(); }
58
+ if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); confirmDialog(); }
59
+ }
60
+
61
+ $effect(() => {
62
+ window.addEventListener('keydown', handleKeydown, true);
63
+ return () => window.removeEventListener('keydown', handleKeydown, true);
64
+ });
65
+ </script>
66
+
67
+ {#if visible && opts}
68
+ <!-- Scrim -->
69
+ <div
70
+ class="cd-scrim"
71
+ transition:fade={{ duration: 120 }}
72
+ role="presentation"
73
+ onclick={cancelDialog}
74
+ ></div>
75
+
76
+ <!-- Dialog -->
77
+ <div
78
+ class="cd-wrap"
79
+ role="alertdialog"
80
+ aria-modal="true"
81
+ aria-label="{opts.title}. {isDanger ? 'Destructive action.' : ''} Press Enter to confirm or Escape to cancel."
82
+ in:fly={{ y: -20, duration: 200, easing: cubicOut }}
83
+ out:fade={{ duration: 120 }}
84
+ >
85
+ <div class="cd-box" class:cd-danger={isDanger}>
86
+ <!-- Icon + title -->
87
+ <div class="cd-header">
88
+ <div class="cd-icon" class:cd-icon-danger={isDanger}>
89
+ {#if isDanger}
90
+ <!-- Terminal/skull icon for destructive actions -->
91
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
92
+ <rect x="2" y="3" width="20" height="14" rx="2"/>
93
+ <path d="M8 21h8M12 17v4"/>
94
+ <path d="M9 8l2 2-2 2M13 12h2"/>
95
+ </svg>
96
+ {:else}
97
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
98
+ <circle cx="12" cy="12" r="10"/>
99
+ <line x1="12" y1="8" x2="12" y2="12"/>
100
+ <line x1="12" y1="16" x2="12.01" y2="16"/>
101
+ </svg>
102
+ {/if}
103
+ </div>
104
+ <div class="cd-title-group">
105
+ <h3 class="cd-title">{opts.title}</h3>
106
+ {#if opts.body}
107
+ <p class="cd-body">{opts.body}</p>
108
+ {/if}
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Actions -->
113
+ <div class="cd-actions">
114
+ <button
115
+ bind:this={cancelRef}
116
+ type="button"
117
+ class="cd-btn cd-btn-cancel"
118
+ onclick={cancelDialog}
119
+ >
120
+ <kbd class="cd-kbd">Esc</kbd>
121
+ <span>{opts.cancelLabel ?? 'Cancel'}</span>
122
+ </button>
123
+
124
+ <button
125
+ type="button"
126
+ class="cd-btn cd-btn-confirm"
127
+ class:cd-btn-danger={isDanger}
128
+ onclick={confirmDialog}
129
+ >
130
+ <kbd class="cd-kbd">{isDanger ? '⏎' : 'Enter'}</kbd>
131
+ <span>{opts.confirmLabel ?? (isDanger ? 'Confirm' : 'OK')}</span>
132
+ </button>
133
+
134
+ {#if total > 0}
135
+ <span class="cd-timer" aria-live="off">{secs}s</span>
136
+ {/if}
137
+ </div>
138
+
139
+ <!-- Progress bar (drains to 0; auto-cancels at 0) -->
140
+ {#if total > 0}
141
+ <div class="cd-progress" aria-hidden="true">
142
+ <div class="cd-progress-fill" class:cd-progress-danger={isDanger} style="width: {pct}%"></div>
143
+ </div>
144
+ {/if}
145
+ </div>
146
+ </div>
147
+ {/if}
148
+
149
+ <style>
150
+ .cd-scrim {
151
+ position: fixed;
152
+ inset: 0;
153
+ background: oklch(0 0 0 / 0.45);
154
+ z-index: 9990;
155
+ backdrop-filter: blur(2px);
156
+ }
157
+
158
+ .cd-wrap {
159
+ position: fixed;
160
+ top: 5rem;
161
+ left: 50%;
162
+ transform: translateX(-50%);
163
+ z-index: 9991;
164
+ pointer-events: auto;
165
+ width: max-content;
166
+ max-width: min(92vw, 460px);
167
+ }
168
+
169
+ .cd-box {
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: 0.625rem;
173
+ padding: 1rem 1.125rem 0.625rem;
174
+ background: color-mix(in oklch, var(--color-base-100) 98%, transparent);
175
+ border: 1px solid var(--color-base-300);
176
+ border-radius: 0.875rem;
177
+ box-shadow:
178
+ 0 20px 60px oklch(0 0 0 / 0.55),
179
+ 0 0 0 1px color-mix(in oklch, var(--color-base-300) 50%, transparent);
180
+ backdrop-filter: blur(16px);
181
+ }
182
+
183
+ .cd-box.cd-danger {
184
+ border-color: color-mix(in oklch, var(--color-error) 55%, transparent);
185
+ box-shadow:
186
+ 0 20px 60px oklch(0 0 0 / 0.6),
187
+ 0 0 0 1px color-mix(in oklch, var(--color-error) 25%, transparent),
188
+ 0 0 30px color-mix(in oklch, var(--color-error) 12%, transparent);
189
+ }
190
+
191
+ .cd-header {
192
+ display: flex;
193
+ align-items: flex-start;
194
+ gap: 0.875rem;
195
+ }
196
+
197
+ .cd-icon {
198
+ flex-shrink: 0;
199
+ width: 2.25rem;
200
+ height: 2.25rem;
201
+ border-radius: 0.5rem;
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ background: color-mix(in oklch, var(--color-info) 35%, transparent);
206
+ border: 1px solid color-mix(in oklch, var(--color-info) 40%, transparent);
207
+ color: var(--color-info);
208
+ }
209
+
210
+ .cd-icon svg {
211
+ width: 1.125rem;
212
+ height: 1.125rem;
213
+ }
214
+
215
+ .cd-icon.cd-icon-danger {
216
+ background: color-mix(in oklch, var(--color-error) 35%, transparent);
217
+ border-color: color-mix(in oklch, var(--color-error) 40%, transparent);
218
+ color: var(--color-error-content);
219
+ }
220
+
221
+ .cd-title-group {
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: 0.25rem;
225
+ flex: 1;
226
+ min-width: 0;
227
+ }
228
+
229
+ .cd-title {
230
+ margin: 0;
231
+ font-size: 0.9375rem;
232
+ font-weight: 600;
233
+ color: var(--color-base-content);
234
+ line-height: 1.3;
235
+ }
236
+
237
+ .cd-body {
238
+ margin: 0;
239
+ font-size: 0.8rem;
240
+ color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
241
+ line-height: 1.5;
242
+ }
243
+
244
+ .cd-actions {
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 0.375rem;
248
+ padding-top: 0.125rem;
249
+ }
250
+
251
+ .cd-btn {
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: 0.375rem;
255
+ padding: 0.3125rem 0.75rem;
256
+ font-size: 0.8rem;
257
+ font-weight: 500;
258
+ border-radius: 0.5rem;
259
+ border: 1px solid transparent;
260
+ background: transparent;
261
+ color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
262
+ cursor: pointer;
263
+ transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease;
264
+ white-space: nowrap;
265
+ }
266
+
267
+ .cd-btn:hover {
268
+ background: var(--color-base-200);
269
+ border-color: var(--color-base-300);
270
+ }
271
+
272
+ .cd-btn-cancel:focus-visible {
273
+ outline: 2px solid var(--color-primary);
274
+ outline-offset: 2px;
275
+ }
276
+
277
+ .cd-btn-confirm {
278
+ background: color-mix(in oklch, var(--color-base-300) 60%, transparent);
279
+ border-color: var(--color-base-300);
280
+ }
281
+
282
+ .cd-btn-confirm:hover {
283
+ background: var(--color-base-200);
284
+ border-color: color-mix(in oklch, var(--color-base-content) 30%, transparent);
285
+ }
286
+
287
+ .cd-btn-danger {
288
+ background: color-mix(in oklch, var(--color-error) 45%, transparent);
289
+ border-color: color-mix(in oklch, var(--color-error) 55%, transparent);
290
+ color: var(--color-error-content);
291
+ }
292
+
293
+ .cd-btn-danger:hover {
294
+ background: color-mix(in oklch, var(--color-error) 65%, transparent);
295
+ border-color: color-mix(in oklch, var(--color-error) 70%, transparent);
296
+ color: var(--color-error-content);
297
+ }
298
+
299
+ .cd-btn-confirm:focus-visible,
300
+ .cd-btn-danger:focus-visible {
301
+ outline: 2px solid var(--color-error);
302
+ outline-offset: 2px;
303
+ }
304
+
305
+ .cd-kbd {
306
+ font-family: ui-monospace, monospace;
307
+ font-size: 0.65rem;
308
+ padding: 0.0625rem 0.3125rem;
309
+ background: var(--color-base-200);
310
+ border: 1px solid var(--color-base-300);
311
+ border-bottom-width: 2px;
312
+ border-radius: 0.25rem;
313
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
314
+ line-height: 1.6;
315
+ }
316
+
317
+ .cd-timer {
318
+ margin-left: auto;
319
+ font-family: ui-monospace, monospace;
320
+ font-size: 0.7rem;
321
+ color: color-mix(in oklch, var(--color-base-content) 45%, transparent);
322
+ font-variant-numeric: tabular-nums;
323
+ min-width: 2ch;
324
+ text-align: right;
325
+ }
326
+
327
+ .cd-progress {
328
+ height: 2px;
329
+ background: var(--color-base-200);
330
+ border-radius: 999px;
331
+ overflow: hidden;
332
+ }
333
+
334
+ .cd-progress-fill {
335
+ height: 100%;
336
+ background: linear-gradient(90deg, var(--color-info), var(--color-info));
337
+ border-radius: 999px;
338
+ transition: width 60ms linear;
339
+ }
340
+
341
+ .cd-progress-fill.cd-progress-danger {
342
+ background: linear-gradient(90deg, var(--color-error), var(--color-error));
343
+ }
344
+
345
+ @media (prefers-reduced-motion: reduce) {
346
+ .cd-progress-fill { transition: none; }
347
+ }
348
+ </style>
@@ -1,5 +1,9 @@
1
1
  <script lang="ts">
2
+ import { fly, fade } from 'svelte/transition';
3
+ import { cubicOut } from 'svelte/easing';
4
+
2
5
  let {
6
+ open = $bindable(true),
3
7
  title = "Confirm Action",
4
8
  message = "Are you sure you want to proceed?",
5
9
  confirmText = "Confirm",
@@ -10,6 +14,7 @@
10
14
  onconfirm,
11
15
  oncancel,
12
16
  } = $props<{
17
+ open?: boolean
13
18
  title?: string
14
19
  message?: string
15
20
  confirmText?: string
@@ -21,19 +26,85 @@
21
26
  oncancel?: () => void
22
27
  }>()
23
28
 
29
+ // Respect prefers-reduced-motion: skip the entrance/exit transitions.
30
+ const reducedMotion =
31
+ typeof window !== 'undefined' &&
32
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
33
+
34
+ // Focus management: move focus into the dialog on open, trap Tab within it,
35
+ // and restore focus to the trigger on close. Escape is handled window-level
36
+ // so it works regardless of which control inside the box currently has focus.
37
+ let modalBox = $state<HTMLElement | null>(null)
38
+ let previousFocus: HTMLElement | null = null
39
+
40
+ const FOCUSABLE =
41
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
42
+
43
+ $effect(() => {
44
+ if (typeof document === 'undefined') return
45
+ if (open) {
46
+ previousFocus = document.activeElement as HTMLElement
47
+ requestAnimationFrame(() => {
48
+ if (modalBox?.contains(document.activeElement)) return
49
+ const first = modalBox?.querySelector<HTMLElement>(FOCUSABLE)
50
+ ;(first ?? modalBox)?.focus()
51
+ })
52
+ } else if (previousFocus) {
53
+ previousFocus.focus?.()
54
+ previousFocus = null
55
+ }
56
+ })
57
+
24
58
  function confirm() {
25
59
  onconfirm?.()
26
60
  }
27
61
 
28
62
  function cancel() {
63
+ open = false
29
64
  oncancel?.()
30
65
  }
66
+
67
+ function onWindowKeydown(e: KeyboardEvent) {
68
+ if (open && e.key === 'Escape') {
69
+ e.preventDefault()
70
+ e.stopImmediatePropagation()
71
+ cancel()
72
+ }
73
+ }
74
+
75
+ function trapTab(e: KeyboardEvent) {
76
+ if (e.key !== 'Tab' || !modalBox) return
77
+ const f = Array.from(modalBox.querySelectorAll<HTMLElement>(FOCUSABLE))
78
+ if (f.length === 0) return
79
+ const first = f[0]
80
+ const last = f[f.length - 1]
81
+ if (e.shiftKey && document.activeElement === first) {
82
+ e.preventDefault()
83
+ last.focus()
84
+ } else if (!e.shiftKey && document.activeElement === last) {
85
+ e.preventDefault()
86
+ first.focus()
87
+ }
88
+ }
31
89
  </script>
32
90
 
33
- <div class="modal modal-open">
34
- <div class="modal-box max-w-md">
91
+ <svelte:window onkeydown={onWindowKeydown} />
92
+
93
+ {#if open}
94
+ <div class="modal modal-open" transition:fade={{ duration: reducedMotion ? 0 : 200 }}>
95
+ <div
96
+ bind:this={modalBox}
97
+ class="modal-box max-w-md"
98
+ role="dialog"
99
+ aria-modal="true"
100
+ aria-label={title}
101
+ tabindex="-1"
102
+ onkeydown={trapTab}
103
+ in:fly={{ y: reducedMotion ? 0 : 10, duration: reducedMotion ? 0 : 220, easing: cubicOut }}
104
+ out:fade={{ duration: reducedMotion ? 0 : 150 }}
105
+ >
35
106
  <div class="flex justify-between items-center mb-4">
36
- <h3 class="font-bold text-lg">{title}</h3>
107
+ <h3 class="font-semibold">{title}</h3>
37
108
  <button class="btn btn-ghost btn-sm" onclick={cancel} aria-label="Close">
38
109
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
39
110
  </button>
@@ -64,12 +135,8 @@
64
135
  </div>
65
136
  </div>
66
137
 
67
- <div
68
- class="modal-backdrop"
69
- onclick={cancel}
70
- onkeydown={(e) => { if (e.key === 'Escape') cancel() }}
71
- role="button"
72
- tabindex="0"
73
- aria-label="Close modal"
74
- ></div>
138
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
139
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
140
+ <div class="modal-backdrop" onclick={cancel}></div>
75
141
  </div>
142
+ {/if}
@@ -6,6 +6,13 @@
6
6
  * Headless primitive that handles positioning, viewport clamping, and
7
7
  * dismiss behavior (click-outside, Escape, right-click elsewhere).
8
8
  *
9
+ * Viewport clamping is measured from the rendered DOM (offsetWidth /
10
+ * offsetHeight) — no estimatedWidth/estimatedHeight props needed. The menu
11
+ * stays inside the viewport regardless of how many items are passed in.
12
+ * `transform-origin: top left` is set on the menu element so a caller's
13
+ * scale-based entrance animation (via `class`) cannot shift the visual
14
+ * rect away from the layout-box position used for clamping.
15
+ *
9
16
  * Usage:
10
17
  * let cm = $state({ open: false, x: 0, y: 0, target: null })
11
18
  *
@@ -28,6 +35,9 @@
28
35
  * />
29
36
  */
30
37
 
38
+ import { scale, fade } from 'svelte/transition';
39
+ import { cubicOut } from 'svelte/easing';
40
+
31
41
  type ActionItem = {
32
42
  label: string;
33
43
  onSelect: () => void;
@@ -48,10 +58,6 @@
48
58
  items: ContextMenuItem[];
49
59
  /** Optional small italic label rendered above the items (e.g. a filename). */
50
60
  header?: string;
51
- /** Estimated menu width in px, used for viewport clamping. Default 200. */
52
- estimatedWidth?: number;
53
- /** Estimated menu height in px, used for viewport clamping. Default 200. */
54
- estimatedHeight?: number;
55
61
  /** Stacking context z-index. Default 70 (above DaisyUI modals at 50). */
56
62
  zIndex?: number;
57
63
  /** Additional CSS class for the menu element. */
@@ -66,8 +72,6 @@
66
72
  y,
67
73
  items,
68
74
  header,
69
- estimatedWidth = 200,
70
- estimatedHeight = 200,
71
75
  zIndex = 70,
72
76
  class: className = '',
73
77
  onClose
@@ -85,17 +89,50 @@
85
89
  close();
86
90
  }
87
91
 
88
- function clamp(value: number, max: number, pad = 8) {
89
- if (value + pad > max) return Math.max(pad, max - pad);
90
- return Math.max(pad, value);
92
+ // Position is measured from the rendered menu so it stays correct as items
93
+ // are added/removed. Initial values match the cursor; the post-render
94
+ // effect below corrects them once the layout box is known.
95
+ let menuEl = $state<HTMLDivElement | null>(null);
96
+ // svelte-ignore state_referenced_locally
97
+ let clampedX = $state(x);
98
+ // svelte-ignore state_referenced_locally
99
+ let clampedY = $state(y);
100
+
101
+ function measureAndClamp() {
102
+ if (typeof window === 'undefined' || !menuEl) return;
103
+ // offset* gives the layout box, ignoring any transform: scale() the
104
+ // caller might apply via a className. getBoundingClientRect would
105
+ // return the post-transform rect and underestimate during entrance
106
+ // animations.
107
+ const w = menuEl.offsetWidth;
108
+ const h = menuEl.offsetHeight;
109
+ const margin = 8;
110
+ clampedX =
111
+ x + w > window.innerWidth - margin
112
+ ? Math.max(margin, window.innerWidth - w - margin)
113
+ : Math.max(margin, x);
114
+ clampedY =
115
+ y + h > window.innerHeight - margin
116
+ ? Math.max(margin, window.innerHeight - h - margin)
117
+ : Math.max(margin, y);
91
118
  }
92
119
 
93
- let clampedX = $derived(
94
- typeof window === 'undefined' ? x : clamp(x, window.innerWidth - estimatedWidth)
95
- );
96
- let clampedY = $derived(
97
- typeof window === 'undefined' ? y : clamp(y, window.innerHeight - estimatedHeight)
98
- );
120
+ // Re-measure whenever the menu opens, the anchor moves, or the item list
121
+ // changes (which can change the menu height).
122
+ $effect(() => {
123
+ if (!open) return;
124
+ // Touch reactive deps so the effect re-runs.
125
+ void x; void y; void items.length;
126
+ // Wait for the menu element to mount before measuring.
127
+ if (menuEl) {
128
+ measureAndClamp();
129
+ } else {
130
+ // Element not mounted yet — fall back to cursor position; the next
131
+ // effect tick will fire once `bind:this` resolves.
132
+ clampedX = x;
133
+ clampedY = y;
134
+ }
135
+ });
99
136
  </script>
100
137
 
101
138
  {#if open}
@@ -113,15 +150,18 @@
113
150
  ></div>
114
151
 
115
152
  <div
153
+ bind:this={menuEl}
116
154
  role="menu"
117
155
  tabindex="-1"
118
- class="fixed min-w-[200px] bg-base-100 border border-base-300 rounded-md shadow-lg py-1 {className}"
119
- style="left: {clampedX}px; top: {clampedY}px; z-index: {zIndex};"
156
+ class="fixed min-w-[200px] bg-base-200 border border-base-300 rounded-md py-1 {className}"
157
+ style="left: {clampedX}px; top: {clampedY}px; z-index: {zIndex}; transform-origin: top left;"
158
+ in:scale={{ start: 0.92, duration: 140, easing: cubicOut }}
159
+ out:fade={{ duration: 100 }}
120
160
  onkeydown={(e) => e.key === 'Escape' && close()}
121
161
  >
122
162
  {#if header}
123
163
  <p
124
- class="px-3 py-1.5 text-[11px] italic text-base-content/60 truncate border-b border-base-200"
164
+ class="px-3 py-1.5 text-[11px] italic text-base-content/60 truncate border-b border-base-300"
125
165
  >
126
166
  {header}
127
167
  </p>
@@ -129,7 +169,7 @@
129
169
 
130
170
  {#each items as item}
131
171
  {#if 'divider' in item}
132
- <div class="border-t border-base-200 my-1"></div>
172
+ <div class="border-t border-base-300 my-1"></div>
133
173
  {:else}
134
174
  <button
135
175
  type="button"
@@ -15,7 +15,7 @@
15
15
  onComplete = () => {},
16
16
  size = "sm",
17
17
  completionMessage = "Complete",
18
- } = $props<TimerProps>()
18
+ }: TimerProps = $props()
19
19
 
20
20
  let minutes = $state(0)
21
21
  let seconds = $state(0)
@@ -23,7 +23,9 @@
23
23
  }: Props = $props();
24
24
 
25
25
  // Local state for custom date inputs
26
+ // svelte-ignore state_referenced_locally
26
27
  let localFrom = $state(customFrom || '');
28
+ // svelte-ignore state_referenced_locally
27
29
  let localTo = $state(customTo || '');
28
30
  let showCustom = $state(false);
29
31
 
@@ -129,7 +131,7 @@
129
131
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
130
132
  <div
131
133
  tabindex="0"
132
- class="dropdown-content rounded-box z-40 w-72 p-3 shadow-lg mt-1 bg-base-200 border border-base-300"
134
+ class="dropdown-content rounded-box z-40 w-72 p-3 mt-1 bg-base-200 border border-base-300"
133
135
  >
134
136
  <!-- Quick Presets -->
135
137
  <div class="flex flex-wrap gap-1.5 mb-3">
@@ -147,7 +149,7 @@
147
149
  <!-- Divider -->
148
150
  <div class="my-2 flex items-center gap-2">
149
151
  <div class="flex-1 h-px bg-base-300"></div>
150
- <span class="text-xs font-mono uppercase tracking-wider text-base-content/40">or custom</span>
152
+ <span class="text-[0.75rem] text-base-content/45">or custom</span>
151
153
  <div class="flex-1 h-px bg-base-300"></div>
152
154
  </div>
153
155
 
@@ -156,7 +158,7 @@
156
158
  <div class="flex gap-2">
157
159
  <div class="form-control flex-1">
158
160
  <label class="label py-0.5" for="date-range-from">
159
- <span class="label-text text-xs font-mono uppercase tracking-wider text-base-content/50">From</span>
161
+ <span class="text-[0.8125rem] text-base-content/45">From</span>
160
162
  </label>
161
163
  <input
162
164
  id="date-range-from"
@@ -168,7 +170,7 @@
168
170
  </div>
169
171
  <div class="form-control flex-1">
170
172
  <label class="label py-0.5" for="date-range-to">
171
- <span class="label-text text-xs font-mono uppercase tracking-wider text-base-content/50">To</span>
173
+ <span class="text-[0.8125rem] text-base-content/45">To</span>
172
174
  </label>
173
175
  <input
174
176
  id="date-range-to"