@joewinke/jatui 0.38.5 → 0.39.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 (67) hide show
  1. package/CLAUDE.md +32 -1
  2. package/THIRD_PARTY_NOTICES.md +51 -0
  3. package/dist/components/BulkActionBar.svelte +32 -2
  4. package/dist/components/BulkActionBar.svelte.d.ts +5 -1
  5. package/dist/components/FloatingActionBar.svelte +102 -34
  6. package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
  7. package/dist/components/MediaExplorer.svelte +249 -0
  8. package/dist/components/MediaExplorer.svelte.d.ts +37 -0
  9. package/dist/components/NotificationCenter.svelte +481 -0
  10. package/dist/components/NotificationCenter.svelte.d.ts +29 -0
  11. package/dist/components/SearchDropdown.svelte +54 -8
  12. package/dist/components/SearchDropdown.svelte.d.ts +12 -0
  13. package/dist/components/VideoPlayer.svelte +269 -3
  14. package/dist/components/agent/AgentLog.svelte +217 -0
  15. package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
  16. package/dist/components/agent/AgentLogRow.svelte +281 -0
  17. package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
  18. package/dist/components/agent/AgentTaskList.svelte +607 -0
  19. package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
  20. package/dist/components/agent/AgentThinking.svelte +280 -0
  21. package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
  22. package/dist/components/agent/ComposerGlow.svelte +265 -0
  23. package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
  24. package/dist/components/agent/ContextMeter.svelte +277 -0
  25. package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
  26. package/dist/components/agent/Questionnaire.svelte +767 -0
  27. package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
  28. package/dist/components/agent/ShimmerText.svelte +100 -0
  29. package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
  30. package/dist/components/agent/StreamedText.svelte +135 -0
  31. package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
  32. package/dist/components/agent/agentLog.d.ts +22 -0
  33. package/dist/components/agent/agentLog.js +26 -0
  34. package/dist/components/agent/agentTaskList.d.ts +90 -0
  35. package/dist/components/agent/agentTaskList.js +93 -0
  36. package/dist/components/agent/agentThinking.d.ts +46 -0
  37. package/dist/components/agent/agentThinking.js +78 -0
  38. package/dist/components/agent/contextMeter.d.ts +97 -0
  39. package/dist/components/agent/contextMeter.js +156 -0
  40. package/dist/components/agent/questionnaire.d.ts +104 -0
  41. package/dist/components/agent/questionnaire.js +225 -0
  42. package/dist/components/agent/revealCore.d.ts +74 -0
  43. package/dist/components/agent/revealCore.js +160 -0
  44. package/dist/components/agent/revealTicker.d.ts +34 -0
  45. package/dist/components/agent/revealTicker.js +73 -0
  46. package/dist/components/agent/streamedText.d.ts +28 -0
  47. package/dist/components/agent/streamedText.js +59 -0
  48. package/dist/components/messaging/MessageInput.svelte +34 -4
  49. package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
  50. package/dist/components/messaging/MessageItem.svelte +2 -2
  51. package/dist/components/notificationCenter.d.ts +65 -0
  52. package/dist/components/notificationCenter.js +110 -0
  53. package/dist/components/progress/Steps.svelte +269 -5
  54. package/dist/components/progress/Steps.svelte.d.ts +16 -1
  55. package/dist/components/progress/stepsSummary.d.ts +14 -0
  56. package/dist/components/progress/stepsSummary.js +32 -0
  57. package/dist/index.d.ts +34 -0
  58. package/dist/index.js +29 -0
  59. package/dist/selection/clickSelection.d.ts +89 -0
  60. package/dist/selection/clickSelection.js +152 -0
  61. package/dist/selection/createClickSelection.d.ts +79 -0
  62. package/dist/selection/createClickSelection.js +134 -0
  63. package/dist/video/keys.d.ts +5 -5
  64. package/dist/video/keys.js +9 -5
  65. package/dist/video/transcriptSearch.d.ts +80 -0
  66. package/dist/video/transcriptSearch.js +153 -0
  67. package/package.json +3 -2
@@ -0,0 +1,481 @@
1
+ <!--
2
+ NotificationCenter — inbox panel: header (title, "N unread", Mark all read),
3
+ category tabs with count chips, and a date-grouped, keyboard-navigable list.
4
+
5
+ MIT License — Copyright (c) 2026 Mertcan Dundar Esmergul (BoardUI)
6
+ Adapted from BoardUI's notification-center.tsx: the header / segmented tabs /
7
+ card layout, the item data model (category, status, avatar, actions[]),
8
+ controlled-or-uncontrolled tab, and "an action click marks the item read".
9
+ jatui additions: configurable `tabs`, Today / Yesterday / This week / Earlier
10
+ date headers (notificationCenter.ts), listNav roving focus with Enter → onOpen,
11
+ host callbacks onMarkRead / onMarkAllRead with optimistic local read state,
12
+ an announced unread marker, and the shared Empty / Avatar components.
13
+ -->
14
+ <script lang="ts">
15
+ import { untrack } from 'svelte';
16
+ import { SvelteSet } from 'svelte/reactivity';
17
+ import { createListNav } from '../actions/listNav';
18
+ import Empty from './basic/Empty.svelte';
19
+ import Avatar from './messaging/Avatar.svelte';
20
+ import { formatDateTime, formatRelativeTime } from '../utils/dateFormatters';
21
+ import {
22
+ DEFAULT_NOTIFICATION_TABS,
23
+ filterByTab,
24
+ groupNotificationsByDate,
25
+ isNotificationUnread,
26
+ tabCounts,
27
+ toTimestampMs,
28
+ unreadIds,
29
+ unreadSummary,
30
+ type NotificationAction,
31
+ type NotificationItem,
32
+ type NotificationStatus,
33
+ type NotificationTab
34
+ } from './notificationCenter';
35
+
36
+ interface Props {
37
+ items: NotificationItem[];
38
+ title?: string;
39
+ /** Tabs across the top. Default All / Mentions / System (by `category`). Pass [] to hide. */
40
+ tabs?: NotificationTab[];
41
+ /** Active tab id (bindable). Leave unbound for uncontrolled use with `defaultTab`. */
42
+ tab?: string;
43
+ defaultTab?: string;
44
+ onTabChange?: (tab: string) => void;
45
+ /** Enter on a focused item, or a click on it / its title. Also marks it read. */
46
+ onOpen?: (item: NotificationItem) => void;
47
+ /** Items became read (opened, or an action was used). */
48
+ onMarkRead?: (ids: string[]) => void;
49
+ /** "Mark all read". When omitted, `onMarkRead` receives every unread id instead. */
50
+ onMarkAllRead?: () => void;
51
+ /** Group the list under Today / Yesterday / This week / Earlier. Default true. */
52
+ groupByDate?: boolean;
53
+ /** Reference "now" for the date groups (tests, demos). Default: live clock. */
54
+ now?: Date | number;
55
+ /** Scroll-area cap — CSS length or px number. Default '32rem'. */
56
+ maxHeight?: string | number;
57
+ emptyTitle?: string;
58
+ emptyDescription?: string;
59
+ class?: string;
60
+ }
61
+
62
+ let {
63
+ items,
64
+ title = 'Notifications',
65
+ tabs = DEFAULT_NOTIFICATION_TABS,
66
+ tab = $bindable(),
67
+ defaultTab,
68
+ onTabChange,
69
+ onOpen,
70
+ onMarkRead,
71
+ onMarkAllRead,
72
+ groupByDate = true,
73
+ now,
74
+ maxHeight = '32rem',
75
+ emptyTitle = "You're all caught up",
76
+ emptyDescription = 'New activity will show up here.',
77
+ class: className = ''
78
+ }: Props = $props();
79
+
80
+ const uid = $props.id();
81
+
82
+ // ---- tabs (controlled via bind:tab / tab, or uncontrolled) -------------
83
+ // svelte-ignore state_referenced_locally
84
+ let internalTab = $state(defaultTab ?? tabs[0]?.id ?? 'all');
85
+ const activeTabId = $derived.by(() => {
86
+ const want = tab ?? internalTab;
87
+ return tabs.some((t) => t.id === want) ? want : (tabs[0]?.id ?? want);
88
+ });
89
+ const activeTab = $derived(tabs.find((t) => t.id === activeTabId));
90
+ const counts = $derived(tabCounts(items, tabs));
91
+ const tabEls: HTMLButtonElement[] = $state([]);
92
+
93
+ function selectTab(id: string, focus = false) {
94
+ internalTab = id;
95
+ tab = id;
96
+ onTabChange?.(id);
97
+ if (focus) tabEls[tabs.findIndex((t) => t.id === id)]?.focus();
98
+ }
99
+
100
+ function onTabKeydown(e: KeyboardEvent, i: number) {
101
+ const n = tabs.length;
102
+ let next = -1;
103
+ if (e.key === 'ArrowRight') next = (i + 1) % n;
104
+ else if (e.key === 'ArrowLeft') next = (i - 1 + n) % n;
105
+ else if (e.key === 'Home') next = 0;
106
+ else if (e.key === 'End') next = n - 1;
107
+ if (next < 0) return;
108
+ e.preventDefault();
109
+ selectTab(tabs[next].id, true);
110
+ }
111
+
112
+ // ---- read state (host-owned, optimistic locally) -----------------------
113
+ const localRead = new SvelteSet<string>();
114
+ const unread = $derived(unreadIds(items, localRead));
115
+
116
+ function markRead(item: NotificationItem) {
117
+ if (!isNotificationUnread(item, localRead)) return;
118
+ localRead.add(item.id);
119
+ onMarkRead?.([item.id]);
120
+ }
121
+
122
+ function markAllRead() {
123
+ const ids = unread;
124
+ if (ids.length === 0) return;
125
+ for (const id of ids) localRead.add(id);
126
+ if (onMarkAllRead) onMarkAllRead();
127
+ else onMarkRead?.(ids);
128
+ }
129
+
130
+ // ---- clock: relative times + date buckets refresh once a minute --------
131
+ let tick = $state(Date.now());
132
+ $effect(() => {
133
+ if (now !== undefined) return;
134
+ const t = setInterval(() => (tick = Date.now()), 60_000);
135
+ return () => clearInterval(t);
136
+ });
137
+ const refNow = $derived(now ?? tick);
138
+
139
+ const visible = $derived(filterByTab(items, activeTab));
140
+ const groups = $derived(
141
+ groupByDate
142
+ ? groupNotificationsByDate(visible, refNow)
143
+ : [{ key: 'all', label: '', items: visible }]
144
+ );
145
+ const byId = $derived(new Map(items.map((it) => [it.id, it])));
146
+
147
+ // ---- list keyboard nav (listNav, scoped to the list) --------------------
148
+ let listEl: HTMLElement | null = $state(null);
149
+ let focusedId = $state<string | null>(null);
150
+ const firstId = $derived(groups[0]?.items[0]?.id ?? null);
151
+
152
+ const nav = createListNav({
153
+ getItems: () =>
154
+ listEl ? Array.from(listEl.querySelectorAll<HTMLElement>('[data-nav-id]')) : [],
155
+ onSelect: (el) => {
156
+ const item = byId.get(el.dataset.navId ?? '');
157
+ if (!item) return;
158
+ const link = el.querySelector<HTMLAnchorElement>('a[data-nc-open]');
159
+ if (link) link.click();
160
+ else open(item);
161
+ },
162
+ onFocusChange: (el, _i, id) => {
163
+ focusedId = id;
164
+ // Move DOM focus with the cursor (roving focus) — but never pull it
165
+ // off a control inside the item (an action button the user tabbed to).
166
+ const active = document.activeElement;
167
+ if (el && !el.contains(active) && listEl?.contains(active)) {
168
+ el.focus({ preventScroll: true });
169
+ }
170
+ },
171
+ wraparound: false,
172
+ focusedClass: 'nc-focused'
173
+ });
174
+
175
+ function onListKeydown(e: KeyboardEvent) {
176
+ // Escape belongs to the host (closing a popover, etc.).
177
+ if (e.key === 'Escape') return;
178
+ nav.handleKeydown(e);
179
+ }
180
+
181
+ function onItemFocusIn(id: string) {
182
+ if (nav.focusedId() === id || !listEl) return;
183
+ const els = Array.from(listEl.querySelectorAll<HTMLElement>('[data-nav-id]'));
184
+ const idx = els.findIndex((el) => el.dataset.navId === id);
185
+ if (idx >= 0) nav.focus(idx);
186
+ }
187
+
188
+ $effect(() => {
189
+ // Re-paint the cursor after the list changes (tab switch, new items).
190
+ void groups;
191
+ untrack(() => nav.refresh());
192
+ });
193
+
194
+ function open(item: NotificationItem) {
195
+ markRead(item);
196
+ onOpen?.(item);
197
+ }
198
+
199
+ function onCardClick(e: MouseEvent, item: NotificationItem) {
200
+ const t = e.target as HTMLElement | null;
201
+ if (t?.closest('button, a, input, select, textarea')) return;
202
+ const link = (e.currentTarget as HTMLElement).querySelector<HTMLAnchorElement>('a[data-nc-open]');
203
+ if (link) link.click();
204
+ else open(item);
205
+ }
206
+
207
+ function runAction(action: NotificationAction, item: NotificationItem) {
208
+ markRead(item);
209
+ action.onSelect(item);
210
+ }
211
+
212
+ const actionClass: Record<NonNullable<NotificationAction['variant']>, string> = {
213
+ primary: 'btn-primary',
214
+ secondary: 'btn-soft',
215
+ ghost: 'btn-ghost',
216
+ danger: 'btn-soft btn-error'
217
+ };
218
+
219
+ const statusTone: Record<NotificationStatus | 'neutral', string> = {
220
+ info: 'bg-info/15 text-info',
221
+ success: 'bg-success/15 text-success',
222
+ warning: 'bg-warning/15 text-warning',
223
+ error: 'bg-error/15 text-error',
224
+ neutral: 'bg-base-200 text-base-content/60'
225
+ };
226
+ const statusLabel: Record<NotificationStatus, string> = {
227
+ info: 'Info',
228
+ success: 'Success',
229
+ warning: 'Warning',
230
+ error: 'Error'
231
+ };
232
+
233
+ const cssLen = (v: string | number) => (typeof v === 'number' ? `${v}px` : v);
234
+ const iso = (ts: NotificationItem['timestamp']) => {
235
+ const ms = toTimestampMs(ts);
236
+ return Number.isNaN(ms) ? undefined : new Date(ms).toISOString();
237
+ };
238
+ const asDate = (ts: NotificationItem['timestamp']) => {
239
+ const ms = toTimestampMs(ts);
240
+ return Number.isNaN(ms) ? null : new Date(ms);
241
+ };
242
+ function relTime(ts: NotificationItem['timestamp'], _tick: number) {
243
+ return formatRelativeTime(asDate(ts));
244
+ }
245
+ </script>
246
+
247
+ <section
248
+ class="nc bg-base-100 border-base-300 rounded-box flex w-full max-w-md flex-col overflow-hidden border {className}"
249
+ aria-labelledby="{uid}-title"
250
+ >
251
+ <header class="flex flex-col gap-3 p-4 pb-2">
252
+ <div class="flex items-start justify-between gap-4">
253
+ <div class="flex min-w-0 flex-col gap-0.5">
254
+ <h2 id="{uid}-title" class="text-base-content text-base font-semibold">{title}</h2>
255
+ <p class="text-base-content/60 text-sm" aria-live="polite">{unreadSummary(unread.length)}</p>
256
+ </div>
257
+ <button
258
+ type="button"
259
+ class="btn btn-ghost btn-sm"
260
+ onclick={markAllRead}
261
+ disabled={unread.length === 0}
262
+ >
263
+ Mark all read
264
+ </button>
265
+ </div>
266
+
267
+ {#if tabs.length > 1}
268
+ <div
269
+ role="tablist"
270
+ aria-label="Notification category"
271
+ class="bg-base-200 rounded-field flex w-full gap-1 p-1"
272
+ >
273
+ {#each tabs as t, i (t.id)}
274
+ {@const selected = t.id === activeTabId}
275
+ <button
276
+ bind:this={tabEls[i]}
277
+ type="button"
278
+ role="tab"
279
+ id="{uid}-tab-{t.id}"
280
+ aria-selected={selected}
281
+ aria-controls="{uid}-panel"
282
+ tabindex={selected ? 0 : -1}
283
+ class="nc-tab rounded-field flex flex-1 items-center justify-center gap-1.5 px-2 py-1 text-sm font-medium"
284
+ class:nc-tab-active={selected}
285
+ onclick={() => selectTab(t.id)}
286
+ onkeydown={(e) => onTabKeydown(e, i)}
287
+ >
288
+ {t.label}
289
+ <span
290
+ class="bg-base-content/10 text-base-content/70 rounded-selector min-w-5 px-1 text-xs tabular-nums"
291
+ >{counts[t.id] ?? 0}</span
292
+ >
293
+ </button>
294
+ {/each}
295
+ </div>
296
+ {/if}
297
+ </header>
298
+
299
+ <div class="bg-base-100 p-1.5 pt-0">
300
+ <div
301
+ id="{uid}-panel"
302
+ role={tabs.length > 1 ? 'tabpanel' : undefined}
303
+ aria-labelledby={tabs.length > 1 ? `${uid}-tab-${activeTabId}` : `${uid}-title`}
304
+ class="bg-base-200 rounded-box overflow-y-auto overscroll-contain p-2"
305
+ style:max-height={cssLen(maxHeight)}
306
+ >
307
+ {#if visible.length === 0}
308
+ <Empty title={emptyTitle} description={emptyDescription} glyph="check" size="sm" />
309
+ {:else}
310
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
311
+ <div bind:this={listEl} class="flex flex-col gap-3" onkeydown={onListKeydown}>
312
+ {#each groups as group (group.key)}
313
+ <div class="flex flex-col gap-1.5">
314
+ {#if group.label}
315
+ <h3
316
+ id="{uid}-g-{group.key}"
317
+ class="text-base-content/50 px-1 text-xs font-semibold tracking-wide uppercase"
318
+ >
319
+ {group.label}
320
+ </h3>
321
+ {/if}
322
+ <ul
323
+ class="flex flex-col gap-1.5"
324
+ aria-labelledby={group.label ? `${uid}-g-${group.key}` : `${uid}-title`}
325
+ >
326
+ {#each group.items as item (item.id)}
327
+ {@const isUnread = isNotificationUnread(item, localRead)}
328
+ {@const status = item.status ?? 'neutral'}
329
+ {@const date = asDate(item.timestamp)}
330
+ <li>
331
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
332
+ <article
333
+ data-nav-id={item.id}
334
+ tabindex={(focusedId ?? firstId) === item.id ? 0 : -1}
335
+ aria-labelledby="{uid}-i-{item.id}"
336
+ aria-describedby={item.description ? `${uid}-d-${item.id}` : undefined}
337
+ class="nc-item bg-base-100 rounded-box relative flex cursor-pointer gap-3 p-3"
338
+ class:nc-unread={isUnread}
339
+ onfocusin={() => onItemFocusIn(item.id)}
340
+ onclick={(e) => onCardClick(e, item)}
341
+ >
342
+ {#if item.avatar}
343
+ <Avatar
344
+ name={item.avatar.name}
345
+ avatarUrl={item.avatar.src ?? null}
346
+ size="medium"
347
+ />
348
+ {:else}
349
+ <span
350
+ class="flex size-10 shrink-0 items-center justify-center rounded-full {statusTone[status]}"
351
+ >
352
+ <svg
353
+ class="size-5"
354
+ viewBox="0 0 24 24"
355
+ fill="none"
356
+ stroke="currentColor"
357
+ stroke-width="1.8"
358
+ stroke-linecap="round"
359
+ stroke-linejoin="round"
360
+ aria-hidden="true"
361
+ >
362
+ {#if status === 'success'}
363
+ <circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.5 2.5 4.5-5" />
364
+ {:else if status === 'error'}
365
+ <circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" />
366
+ {:else if status === 'warning'}
367
+ <path d="M10.3 4.1 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0Z" /><path d="M12 9.5v4M12 17h.01" />
368
+ {:else if status === 'info'}
369
+ <circle cx="12" cy="12" r="9" /><path d="M12 11v5.5M12 7.5h.01" />
370
+ {:else}
371
+ <path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0" />
372
+ {/if}
373
+ </svg>
374
+ {#if item.status}<span class="sr-only">{statusLabel[item.status]}:</span>{/if}
375
+ </span>
376
+ {/if}
377
+
378
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
379
+ <div class="flex min-w-0 items-start justify-between gap-3">
380
+ {#if item.href}
381
+ <a
382
+ id="{uid}-i-{item.id}"
383
+ data-nc-open
384
+ href={item.href}
385
+ tabindex="-1"
386
+ class="text-base-content min-w-0 text-sm font-medium hover:underline"
387
+ class:font-semibold={isUnread}
388
+ onclick={() => open(item)}>{item.title}</a
389
+ >
390
+ {:else}
391
+ <p
392
+ id="{uid}-i-{item.id}"
393
+ class="text-base-content min-w-0 text-sm font-medium"
394
+ class:font-semibold={isUnread}
395
+ >
396
+ {item.title}
397
+ </p>
398
+ {/if}
399
+ <span class="flex shrink-0 items-center gap-2">
400
+ <time
401
+ class="text-base-content/50 text-xs whitespace-nowrap tabular-nums"
402
+ datetime={iso(item.timestamp)}
403
+ title={date ? formatDateTime(date) : undefined}
404
+ >{relTime(item.timestamp, tick)}</time
405
+ >
406
+ {#if isUnread}
407
+ <span class="bg-primary size-2 rounded-full" aria-hidden="true"></span>
408
+ <span class="sr-only">Unread</span>
409
+ {/if}
410
+ </span>
411
+ </div>
412
+ {#if item.description}
413
+ <p id="{uid}-d-{item.id}" class="text-base-content/70 text-sm">
414
+ {item.description}
415
+ </p>
416
+ {/if}
417
+ {#if item.actions?.length}
418
+ <div class="mt-1.5 flex flex-wrap items-center gap-2">
419
+ {#each item.actions as action, ai (ai)}
420
+ <button
421
+ type="button"
422
+ class="btn btn-xs {actionClass[action.variant ?? 'secondary']}"
423
+ onclick={() => runAction(action, item)}
424
+ >
425
+ {action.label}
426
+ </button>
427
+ {/each}
428
+ </div>
429
+ {/if}
430
+ </div>
431
+ </article>
432
+ </li>
433
+ {/each}
434
+ </ul>
435
+ </div>
436
+ {/each}
437
+ </div>
438
+ {/if}
439
+ </div>
440
+ </div>
441
+ </section>
442
+
443
+ <style>
444
+ .nc-tab {
445
+ color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
446
+ transition:
447
+ background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out),
448
+ color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
449
+ }
450
+ .nc-tab:hover {
451
+ color: var(--color-base-content);
452
+ }
453
+ .nc-tab-active {
454
+ background: var(--color-base-100);
455
+ color: var(--color-base-content);
456
+ }
457
+ .nc-tab:focus-visible {
458
+ outline: 2px solid var(--color-primary);
459
+ outline-offset: 1px;
460
+ }
461
+ .nc-item {
462
+ border-radius: var(--jatui-radius-box);
463
+ transition: background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
464
+ }
465
+ .nc-item:hover {
466
+ background: color-mix(in oklab, var(--color-base-100) 92%, var(--color-base-content));
467
+ }
468
+ .nc-item.nc-unread {
469
+ background: color-mix(in oklab, var(--color-base-100) 94%, var(--color-primary));
470
+ }
471
+ .nc-item:focus-visible {
472
+ outline: 2px solid var(--color-primary);
473
+ outline-offset: -2px;
474
+ }
475
+ @media (prefers-reduced-motion: reduce) {
476
+ .nc-tab,
477
+ .nc-item {
478
+ transition: none;
479
+ }
480
+ }
481
+ </style>
@@ -0,0 +1,29 @@
1
+ import { type NotificationItem, type NotificationTab } from './notificationCenter';
2
+ interface Props {
3
+ items: NotificationItem[];
4
+ title?: string;
5
+ /** Tabs across the top. Default All / Mentions / System (by `category`). Pass [] to hide. */
6
+ tabs?: NotificationTab[];
7
+ /** Active tab id (bindable). Leave unbound for uncontrolled use with `defaultTab`. */
8
+ tab?: string;
9
+ defaultTab?: string;
10
+ onTabChange?: (tab: string) => void;
11
+ /** Enter on a focused item, or a click on it / its title. Also marks it read. */
12
+ onOpen?: (item: NotificationItem) => void;
13
+ /** Items became read (opened, or an action was used). */
14
+ onMarkRead?: (ids: string[]) => void;
15
+ /** "Mark all read". When omitted, `onMarkRead` receives every unread id instead. */
16
+ onMarkAllRead?: () => void;
17
+ /** Group the list under Today / Yesterday / This week / Earlier. Default true. */
18
+ groupByDate?: boolean;
19
+ /** Reference "now" for the date groups (tests, demos). Default: live clock. */
20
+ now?: Date | number;
21
+ /** Scroll-area cap — CSS length or px number. Default '32rem'. */
22
+ maxHeight?: string | number;
23
+ emptyTitle?: string;
24
+ emptyDescription?: string;
25
+ class?: string;
26
+ }
27
+ declare const NotificationCenter: import("svelte").Component<Props, {}, "tab">;
28
+ type NotificationCenter = ReturnType<typeof NotificationCenter>;
29
+ export default NotificationCenter;
@@ -40,6 +40,8 @@
40
40
  searchDebounceMs = 250,
41
41
  onCreate,
42
42
  createLabel = (q: string) => `Create "${q}"`,
43
+ option: optionSnippet,
44
+ searchLabel,
43
45
  }: {
44
46
  value: string;
45
47
  groups: SearchDropdownGroup[];
@@ -92,8 +94,22 @@
92
94
  onCreate?: (query: string) => void;
93
95
  /** Label for the create row; receives the trimmed query. */
94
96
  createLabel?: (query: string) => string;
97
+ /**
98
+ * Custom body for each option row (replaces the default avatar, label and
99
+ * check). The row itself (click, highlight, keyboard) stays the
100
+ * component's; the snippet only draws what is inside it — e.g. a colour
101
+ * dot, the label with the matched letters marked, and a status note.
102
+ */
103
+ option?: Snippet<[SearchDropdownOption, { highlighted: boolean; selected: boolean }]>;
104
+ /** Accessible name for the search box and its list (defaults to the placeholder). */
105
+ searchLabel?: string;
95
106
  } = $props();
96
107
 
108
+ // ARIA combobox wiring: the search box owns the listbox and points at the
109
+ // highlighted row, so arrows + Enter are announced without moving focus.
110
+ const listId = `sd-${Math.random().toString(36).slice(2, 9)}`;
111
+ const optionId = (i: number) => `${listId}-${i}`;
112
+
97
113
  let open = $state(false);
98
114
  let searchQuery = $state('');
99
115
  let highlightedIndex = $state(-1);
@@ -166,10 +182,18 @@
166
182
  highlightedIndex = idx >= 0 ? idx : 0;
167
183
  });
168
184
 
185
+ // Embedded panels stay open after a pick (several picks in a row): hand
186
+ // focus back to the search box so the next one is typed straight away,
187
+ // even when the pick was a click that moved focus onto the row.
188
+ function refocusEmbedded() {
189
+ if (embedded) tick().then(() => searchInput?.focus());
190
+ }
191
+
169
192
  function select(optionValue: string) {
170
193
  open = false;
171
194
  searchQuery = '';
172
195
  onChange(optionValue);
196
+ refocusEmbedded();
173
197
  }
174
198
 
175
199
  function doCreate() {
@@ -178,6 +202,7 @@
178
202
  open = false;
179
203
  searchQuery = '';
180
204
  onCreate(q);
205
+ refocusEmbedded();
181
206
  }
182
207
 
183
208
  function scrollHighlightedIntoView() {
@@ -202,6 +227,9 @@
202
227
 
203
228
  function handleSearchKeydown(e: KeyboardEvent) {
204
229
  if (e.key === 'Escape') {
230
+ // An embedded panel with an empty search has nothing of its own to
231
+ // close: let Escape reach the host (e.g. close the dialog it sits in).
232
+ if (embedded && !searchQuery) return;
205
233
  e.stopPropagation();
206
234
  open = false;
207
235
  searchQuery = '';
@@ -319,6 +347,12 @@
319
347
  {placeholder}
320
348
  class="sd-search-input"
321
349
  autocomplete="off"
350
+ role="combobox"
351
+ aria-label={searchLabel ?? placeholder}
352
+ aria-expanded={open || embedded}
353
+ aria-controls={listId}
354
+ aria-autocomplete="list"
355
+ aria-activedescendant={(open || embedded) && highlightedIndex >= 0 && highlightedIndex <= maxIndex ? optionId(highlightedIndex) : undefined}
322
356
  />
323
357
  {#if searchQuery}
324
358
  <button type="button" aria-label="Clear search" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear">
@@ -329,17 +363,21 @@
329
363
  </div>
330
364
 
331
365
  <!-- Options list -->
332
- <ul class="sd-list" bind:this={optionListRef}>
366
+ <ul class="sd-list" bind:this={optionListRef} id={listId} role="listbox" aria-label={searchLabel ?? placeholder}>
333
367
  {#if filteredGroups.length > 0}
334
368
  {#each filteredGroups as group, _gi}
335
- <li class="sd-group-label">
369
+ <li class="sd-group-label" role="presentation">
336
370
  <span>{group.label}</span>
337
371
  </li>
338
372
  {#each group.options as option}
339
373
  {@const flatIdx = flatOptions.indexOf(option)}
340
- <li>
374
+ <li role="presentation">
341
375
  <button
342
376
  type="button"
377
+ id={optionId(flatIdx)}
378
+ role="option"
379
+ tabindex="-1"
380
+ aria-selected={flatIdx === highlightedIndex}
343
381
  onclick={() => select(option.value)}
344
382
  class="sd-option"
345
383
  class:sd-option-selected={value === option.value}
@@ -351,7 +389,9 @@
351
389
  : `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};`
352
390
  : ''}
353
391
  >
354
- {#if option.image}
392
+ {#if optionSnippet}
393
+ {@render optionSnippet(option, { highlighted: flatIdx === highlightedIndex, selected: value === option.value })}
394
+ {:else if option.image}
355
395
  <img src={option.image} alt="" class="sd-avatar" />
356
396
  {:else if option.icon}
357
397
  <span class="sd-option-icon">{option.icon}</span>
@@ -360,12 +400,14 @@
360
400
  {option.label.charAt(0).toUpperCase()}
361
401
  </span>
362
402
  {/if}
363
- {#if variant === 'chip' && colorFn && colorFn(option.value)}
403
+ {#if optionSnippet}
404
+ <!-- the snippet drew the whole row body -->
405
+ {:else if variant === 'chip' && colorFn && colorFn(option.value)}
364
406
  <span class="sd-option-chip">{option.label}</span>
365
407
  {:else}
366
408
  <span class="truncate">{option.label}</span>
367
409
  {/if}
368
- {#if value === option.value}
410
+ {#if !optionSnippet && value === option.value}
369
411
  <svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
370
412
  {/if}
371
413
  </button>
@@ -373,13 +415,17 @@
373
415
  {/each}
374
416
  {/each}
375
417
  {:else if !canCreate}
376
- <li class="sd-empty">No matches for "{searchQuery}"</li>
418
+ <li class="sd-empty" role="presentation">No matches for "{searchQuery}"</li>
377
419
  {/if}
378
420
  {#if canCreate}
379
421
  {@const createIdx = flatOptions.length}
380
- <li>
422
+ <li role="presentation">
381
423
  <button
382
424
  type="button"
425
+ id={optionId(createIdx)}
426
+ role="option"
427
+ tabindex="-1"
428
+ aria-selected={highlightedIndex === createIdx}
383
429
  onclick={doCreate}
384
430
  class="sd-option sd-option-create"
385
431
  class:sd-option-highlighted={highlightedIndex === createIdx}
@@ -61,6 +61,18 @@ type $$ComponentProps = {
61
61
  onCreate?: (query: string) => void;
62
62
  /** Label for the create row; receives the trimmed query. */
63
63
  createLabel?: (query: string) => string;
64
+ /**
65
+ * Custom body for each option row (replaces the default avatar, label and
66
+ * check). The row itself (click, highlight, keyboard) stays the
67
+ * component's; the snippet only draws what is inside it — e.g. a colour
68
+ * dot, the label with the matched letters marked, and a status note.
69
+ */
70
+ option?: Snippet<[SearchDropdownOption, {
71
+ highlighted: boolean;
72
+ selected: boolean;
73
+ }]>;
74
+ /** Accessible name for the search box and its list (defaults to the placeholder). */
75
+ searchLabel?: string;
64
76
  };
65
77
  declare const SearchDropdown: import("svelte").Component<$$ComponentProps, {
66
78
  openDropdown: () => void;