@joewinke/jatui 0.38.6 → 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.
- package/CLAUDE.md +31 -0
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/dist/components/BulkActionBar.svelte +32 -2
- package/dist/components/BulkActionBar.svelte.d.ts +5 -1
- package/dist/components/FloatingActionBar.svelte +102 -34
- package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
- package/dist/components/MediaExplorer.svelte +249 -0
- package/dist/components/MediaExplorer.svelte.d.ts +37 -0
- package/dist/components/NotificationCenter.svelte +481 -0
- package/dist/components/NotificationCenter.svelte.d.ts +29 -0
- package/dist/components/SearchDropdown.svelte +54 -8
- package/dist/components/SearchDropdown.svelte.d.ts +12 -0
- package/dist/components/agent/AgentLog.svelte +217 -0
- package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
- package/dist/components/agent/AgentLogRow.svelte +281 -0
- package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
- package/dist/components/agent/AgentTaskList.svelte +607 -0
- package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
- package/dist/components/agent/AgentThinking.svelte +280 -0
- package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
- package/dist/components/agent/ComposerGlow.svelte +265 -0
- package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
- package/dist/components/agent/ContextMeter.svelte +277 -0
- package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
- package/dist/components/agent/Questionnaire.svelte +767 -0
- package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
- package/dist/components/agent/ShimmerText.svelte +100 -0
- package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
- package/dist/components/agent/StreamedText.svelte +135 -0
- package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
- package/dist/components/agent/agentLog.d.ts +22 -0
- package/dist/components/agent/agentLog.js +26 -0
- package/dist/components/agent/agentTaskList.d.ts +90 -0
- package/dist/components/agent/agentTaskList.js +93 -0
- package/dist/components/agent/agentThinking.d.ts +46 -0
- package/dist/components/agent/agentThinking.js +78 -0
- package/dist/components/agent/contextMeter.d.ts +97 -0
- package/dist/components/agent/contextMeter.js +156 -0
- package/dist/components/agent/questionnaire.d.ts +104 -0
- package/dist/components/agent/questionnaire.js +225 -0
- package/dist/components/agent/revealCore.d.ts +74 -0
- package/dist/components/agent/revealCore.js +160 -0
- package/dist/components/agent/revealTicker.d.ts +34 -0
- package/dist/components/agent/revealTicker.js +73 -0
- package/dist/components/agent/streamedText.d.ts +28 -0
- package/dist/components/agent/streamedText.js +59 -0
- package/dist/components/messaging/MessageInput.svelte +34 -4
- package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
- package/dist/components/messaging/MessageItem.svelte +2 -2
- package/dist/components/notificationCenter.d.ts +65 -0
- package/dist/components/notificationCenter.js +110 -0
- package/dist/components/progress/Steps.svelte +269 -5
- package/dist/components/progress/Steps.svelte.d.ts +16 -1
- package/dist/components/progress/stepsSummary.d.ts +14 -0
- package/dist/components/progress/stepsSummary.js +32 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +29 -0
- package/dist/selection/clickSelection.d.ts +89 -0
- package/dist/selection/clickSelection.js +152 -0
- package/dist/selection/createClickSelection.d.ts +79 -0
- package/dist/selection/createClickSelection.js +134 -0
- 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
|
|
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
|
|
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;
|