anentrypoint-design 0.0.229 → 0.0.231
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/app-shell.css +21 -2
- package/chat.css +16 -13
- package/dist/247420.css +37 -15
- package/dist/247420.js +15 -15
- package/package.json +1 -1
- package/src/components/agent-chat.js +14 -3
- package/src/components/chat.js +64 -14
- package/src/components/context-pane.js +23 -8
- package/src/components/files-modals.js +21 -10
- package/src/components/files.js +3 -6
- package/src/components/overlay-primitives.js +84 -19
- package/src/components/sessions.js +15 -6
- package/src/components/shell.js +38 -16
- package/src/kits/os/theme.css +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.231",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -234,6 +234,7 @@ export function AgentChat(props = {}) {
|
|
|
234
234
|
if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
|
|
235
235
|
else parts.push({ kind: 'text', text: part.text, mdShell: true });
|
|
236
236
|
}
|
|
237
|
+
else if (!isStreaming && part.kind === 'thinking') parts.push({ kind: 'thinking', settled: true, text: part.text });
|
|
237
238
|
else parts.push(part);
|
|
238
239
|
}
|
|
239
240
|
}
|
|
@@ -245,6 +246,15 @@ export function AgentChat(props = {}) {
|
|
|
245
246
|
// The streaming caret rides the live assistant turn once it has body (the
|
|
246
247
|
// empty-shell turn already shows the inline typing dots).
|
|
247
248
|
const streaming = isStreaming && msgHasBody(m);
|
|
249
|
+
// Place the caret inline inside the last text/md part rather than as a
|
|
250
|
+
// sibling span (which renders as a block below the last bubble). Tag the
|
|
251
|
+
// last text part so PART_RENDERERS.text can append it as an inline child.
|
|
252
|
+
if (streaming && parts.length) {
|
|
253
|
+
const lastPart = parts[parts.length - 1];
|
|
254
|
+
if (lastPart && (lastPart.kind === 'text' || lastPart.kind === 'md')) {
|
|
255
|
+
parts[parts.length - 1] = { ...lastPart, streamingCaret: true };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
248
258
|
// Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
|
|
249
259
|
// build the action row only for SETTLED messages (no actions mid-stream).
|
|
250
260
|
let actions;
|
|
@@ -252,6 +262,7 @@ export function AgentChat(props = {}) {
|
|
|
252
262
|
const built = [];
|
|
253
263
|
if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
254
264
|
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
265
|
+
if (!isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry', onClick: () => onRetryMessage(m) });
|
|
255
266
|
// With confirmEdit the host arms its own confirm affordance (onArmEdit)
|
|
256
267
|
// instead of resending immediately; the kit stays stateless either way.
|
|
257
268
|
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
|
|
@@ -317,7 +328,7 @@ export function AgentChat(props = {}) {
|
|
|
317
328
|
// code / cowork surface these after a turn, not only on an empty thread). Shown
|
|
318
329
|
// only when not busy and the last message is an assistant turn with body.
|
|
319
330
|
const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
|
|
320
|
-
? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
|
|
331
|
+
? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups', 'aria-hidden': 'true' },
|
|
321
332
|
...followups.map((s, i) => h('button', {
|
|
322
333
|
key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
|
|
323
334
|
onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
|
|
@@ -376,8 +387,8 @@ export function AgentChat(props = {}) {
|
|
|
376
387
|
onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
|
|
377
388
|
...(banners || []).filter(Boolean),
|
|
378
389
|
h('div', { class: 'agentchat-head' },
|
|
379
|
-
h('
|
|
380
|
-
h('span', { class: 'agentchat-sub', 'aria-
|
|
390
|
+
h('h1', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
|
|
391
|
+
h('span', { class: 'agentchat-sub', 'aria-hidden': busy ? 'true' : null },
|
|
381
392
|
// Derive the busy label from the same status prop the controls use, so a
|
|
382
393
|
// reconnecting-while-streaming state reads one word everywhere instead of
|
|
383
394
|
// the head saying "streaming…" while the controls say "reconnecting…".
|
package/src/components/chat.js
CHANGED
|
@@ -8,6 +8,12 @@ import { isDegraded as isMarkdownDegraded } from '../markdown.js';
|
|
|
8
8
|
import { register } from '../debug.js';
|
|
9
9
|
import { Icon } from './shell.js';
|
|
10
10
|
import { fmtFileSize } from './files.js';
|
|
11
|
+
import { EmojiPicker } from './overlay-primitives.js';
|
|
12
|
+
|
|
13
|
+
// Matches a trailing `:keyword` at the end of the composer draft (optionally
|
|
14
|
+
// preceded by whitespace/start-of-string) so typing `:smile` opens an inline
|
|
15
|
+
// filtered EmojiPicker without requiring the toolbar button or Ctrl+;.
|
|
16
|
+
const EMOJI_TRIGGER_RE = /(?:^|\s)(:([a-zA-Z0-9_+-]{0,24}))$/;
|
|
11
17
|
|
|
12
18
|
const h = webjsx.createElement;
|
|
13
19
|
let _stats = { messages: 0, lastKindCounts: {} };
|
|
@@ -256,6 +262,12 @@ function ToolCallNode(p) {
|
|
|
256
262
|
}
|
|
257
263
|
|
|
258
264
|
function ThinkingNode(p) {
|
|
265
|
+
if (p.settled) {
|
|
266
|
+
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
267
|
+
h('summary', {}, 'View thinking'),
|
|
268
|
+
h('div', { class: 'chat-thinking-body' }, p.text)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
259
271
|
return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
|
|
260
272
|
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
261
273
|
h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
|
|
@@ -271,8 +283,11 @@ const PART_RENDERERS = {
|
|
|
271
283
|
// the tail-window path ('streaming · N KB so far').
|
|
272
284
|
? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
|
|
273
285
|
...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
|
|
274
|
-
h('pre', { key: 'pre' }, h('code', {}, p.text || ''))
|
|
275
|
-
|
|
286
|
+
h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
|
|
287
|
+
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
|
|
288
|
+
: h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
|
|
289
|
+
...renderInline(p.text || ''),
|
|
290
|
+
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
|
|
276
291
|
md: (p) => MdNode(p),
|
|
277
292
|
code: (p) => CodeNode(p),
|
|
278
293
|
tool: (p) => ToolCallNode(p),
|
|
@@ -354,7 +369,10 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
354
369
|
// AND already shows content (so the inline typing dots have stopped), append
|
|
355
370
|
// a thin caret so the live edge reads as "still writing", not "done". Drawn as
|
|
356
371
|
// a CSS element, not a glyph character.
|
|
357
|
-
|
|
372
|
+
// Only append the caret as a sibling if the last part did not already embed
|
|
373
|
+
// it inline (streamingCaret flag on the last text/md part in parts array).
|
|
374
|
+
const lastPartHasCaret = parts && parts.length && parts[parts.length - 1] && parts[parts.length - 1].streamingCaret;
|
|
375
|
+
if (streaming && !typing && !lastPartHasCaret) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
|
|
358
376
|
// Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
|
|
359
377
|
// stopped — the turn was cancelled (locally or remotely); truncated
|
|
360
378
|
// output must not read as a finished answer.
|
|
@@ -424,7 +442,7 @@ function flashComposerNote(composerEl, text) {
|
|
|
424
442
|
note._dsNoteTimer = setTimeout(() => { note.remove(); }, 2600);
|
|
425
443
|
}
|
|
426
444
|
|
|
427
|
-
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context, onPasteFiles, onDropFiles }) {
|
|
445
|
+
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles }) {
|
|
428
446
|
// Keep a handle to the live textarea so send() reads the actual DOM value
|
|
429
447
|
// (not the possibly-lagging `value` prop) and so we can sync the DOM value
|
|
430
448
|
// only when it genuinely differs — re-applying `value` on every parent
|
|
@@ -435,6 +453,24 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
435
453
|
if (!v || disabled) return;
|
|
436
454
|
if (onSend) onSend(v);
|
|
437
455
|
};
|
|
456
|
+
const triggerMatch = EMOJI_TRIGGER_RE.exec(value || '');
|
|
457
|
+
// taEl is only assigned by taRef during DOM diffing, which happens AFTER
|
|
458
|
+
// this render function returns — so on first paint of a trigger it is
|
|
459
|
+
// still null here. Fall back to the live DOM textarea from the previous
|
|
460
|
+
// paint (same composer, content patched in place) so the picker anchors
|
|
461
|
+
// near the input instead of the viewport origin.
|
|
462
|
+
const anchorEl = taEl || (typeof document !== 'undefined' ? document.querySelector('.chat-composer textarea') : null);
|
|
463
|
+
const insertEmoji = (ch) => {
|
|
464
|
+
const v = (taEl && taEl.value) || value || '';
|
|
465
|
+
const m = EMOJI_TRIGGER_RE.exec(v);
|
|
466
|
+
const next = m ? (v.slice(0, m.index) + (m[0].startsWith(':') ? '' : v[m.index]) + ch + ' ') : (v + ch);
|
|
467
|
+
if (onInput) onInput(next);
|
|
468
|
+
if (taEl) {
|
|
469
|
+
taEl.value = next;
|
|
470
|
+
taEl.focus();
|
|
471
|
+
taEl.selectionStart = taEl.selectionEnd = next.length;
|
|
472
|
+
}
|
|
473
|
+
};
|
|
438
474
|
let autoGrowScheduled = false;
|
|
439
475
|
const autoGrow = (e) => {
|
|
440
476
|
const ta = e.target;
|
|
@@ -495,7 +531,7 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
495
531
|
}, b.text));
|
|
496
532
|
else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
|
|
497
533
|
});
|
|
498
|
-
contextLine = h('div', { class: 'chat-composer-context' }, ...kids);
|
|
534
|
+
contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
|
|
499
535
|
} else if (ctxBits.length) {
|
|
500
536
|
const joined = ctxBits.map((b) => b.text).join(' · ');
|
|
501
537
|
contextLine = h(context.onClick ? 'button' : 'div', {
|
|
@@ -505,8 +541,16 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
505
541
|
}, joined);
|
|
506
542
|
}
|
|
507
543
|
const hasDraft = !!(value && value.trim());
|
|
544
|
+
const triggerPicker = triggerMatch ? EmojiPicker({
|
|
545
|
+
open: true,
|
|
546
|
+
anchorX: (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect().left : 0,
|
|
547
|
+
anchorY: (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect().top : 0,
|
|
548
|
+
query: triggerMatch[2] || '',
|
|
549
|
+
onSelect: (ch) => insertEmoji(ch),
|
|
550
|
+
onClose: () => { if (taEl) { const v = taEl.value.replace(EMOJI_TRIGGER_RE, (full, tail) => full.slice(0, full.length - tail.length)); if (onInput) onInput(v); taEl.value = v; taEl.focus(); } },
|
|
551
|
+
}) : null;
|
|
508
552
|
return h('div', {
|
|
509
|
-
class: 'chat-composer' + (hasDraft ? ' has-draft' : ''),
|
|
553
|
+
class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
|
|
510
554
|
// A drop on the composer must NEVER navigate the browser away from the
|
|
511
555
|
// live session: preventDefault on both dragover and drop, route files to
|
|
512
556
|
// the optional onDropFiles handler, ring via .dragover.
|
|
@@ -523,23 +567,27 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
523
567
|
},
|
|
524
568
|
},
|
|
525
569
|
contextLine,
|
|
526
|
-
|
|
570
|
+
triggerPicker,
|
|
571
|
+
h('textarea', { ref: taRef, placeholder, rows: 1,
|
|
572
|
+
'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
|
|
527
573
|
disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
|
|
528
574
|
oninput: autoGrow,
|
|
529
575
|
onpaste: (e) => {
|
|
530
576
|
const cd = e.clipboardData;
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
if (cd && cd.files && cd.files.length
|
|
577
|
+
// If the clipboard contains files, always route them — even when
|
|
578
|
+
// text is also present (some apps attach a filename as text).
|
|
579
|
+
if (cd && cd.files && cd.files.length) {
|
|
534
580
|
e.preventDefault();
|
|
535
581
|
if (onPasteFiles) onPasteFiles(cd.files);
|
|
536
582
|
else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
|
|
537
583
|
}
|
|
538
584
|
},
|
|
539
585
|
onkeydown: (e) => {
|
|
540
|
-
// Escape
|
|
541
|
-
|
|
542
|
-
|
|
586
|
+
// Escape blurs the textarea when idle; stops generation when busy.
|
|
587
|
+
if (e.key === 'Escape') {
|
|
588
|
+
if (!busy) { e.currentTarget.blur(); return; }
|
|
589
|
+
if (onCancel) { e.preventDefault(); onCancel(e); return; }
|
|
590
|
+
}
|
|
543
591
|
// IME guard: the Enter that commits a CJK composition must never
|
|
544
592
|
// send (isComposing; keyCode 229 covers older engines).
|
|
545
593
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); send(); }
|
|
@@ -555,7 +603,9 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
555
603
|
onMenu ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onMenu(e); }, 'aria-label': 'composer menu', title: 'more options' }, Icon('more-horizontal')) : null,
|
|
556
604
|
busy && onCancel
|
|
557
605
|
? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
|
|
558
|
-
: h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
|
|
606
|
+
: h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
|
|
607
|
+
'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
|
|
608
|
+
title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
|
|
559
609
|
)
|
|
560
610
|
);
|
|
561
611
|
}
|
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
// ContextPane({ agent, model, cwd, toolCount, onSetCwd })
|
|
9
9
|
//
|
|
10
10
|
// Props:
|
|
11
|
-
// agent
|
|
12
|
-
// model
|
|
13
|
-
// cwd
|
|
14
|
-
// toolCount
|
|
15
|
-
// usage
|
|
16
|
-
// session
|
|
17
|
-
//
|
|
11
|
+
// agent : display name of the active agent (string) or falsy for "none"
|
|
12
|
+
// model : model id/name (string) or falsy
|
|
13
|
+
// cwd : the chat working directory (string) or falsy for server default
|
|
14
|
+
// toolCount : number of tool calls running in the current live turn (>=0)
|
|
15
|
+
// usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
|
|
16
|
+
// session : OPTIONAL whole-conversation totals { turns, cost } shown as a block
|
|
17
|
+
// recentFiles: OPTIONAL [{ path, time }] files touched by tool calls this
|
|
18
|
+
// session (most-recent first), rendered as a compact panel -
|
|
19
|
+
// Claude Desktop's context surfaces recently-touched files.
|
|
20
|
+
// onSetCwd : optional callback for the "set working directory" affordance
|
|
21
|
+
// onOpenFile : optional callback(path) for clicking a recent-files row
|
|
18
22
|
//
|
|
19
23
|
// No decorative glyphs — words + the kit's Icon SVGs only.
|
|
20
24
|
|
|
@@ -32,7 +36,7 @@ function fmtTok(n) {
|
|
|
32
36
|
return (n / 1000000).toFixed(1) + 'M';
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
|
|
39
|
+
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, recentFiles, onSetCwd, onOpenFile } = {}) {
|
|
36
40
|
const running = Number(toolCount) > 0;
|
|
37
41
|
const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
|
|
38
42
|
const hasSession = session && (session.turns != null || session.cost != null);
|
|
@@ -93,6 +97,17 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
93
97
|
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
|
|
94
98
|
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
95
99
|
}
|
|
100
|
+
// Recent files: files touched by tool calls this session, most-recent
|
|
101
|
+
// first, capped to 5 rows so the panel stays a glance not a log.
|
|
102
|
+
if (Array.isArray(recentFiles) && recentFiles.length) {
|
|
103
|
+
const fileRows = recentFiles.slice(0, 5).map((f) => Row({
|
|
104
|
+
title: f.path.split(/[/\\]/).filter(Boolean).pop() || f.path,
|
|
105
|
+
sub: f.path,
|
|
106
|
+
meta: f.time || undefined,
|
|
107
|
+
onClick: onOpenFile ? () => onOpenFile(f.path) : undefined,
|
|
108
|
+
}));
|
|
109
|
+
panels.push(Panel({ title: 'recent files', children: fileRows }));
|
|
110
|
+
}
|
|
96
111
|
// The cwd action lives on the working-dir row above; no floating footer button.
|
|
97
112
|
return h('div', { class: 'ds-context' }, ...panels);
|
|
98
113
|
}
|
|
@@ -6,7 +6,14 @@ import { fileGlyph, fmtFileSize } from './files.js';
|
|
|
6
6
|
import { highlightAllUnder } from '../highlight.js';
|
|
7
7
|
const h = webjsx.createElement;
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// Stable per-call-site id source for aria-labelledby: we want one fixed id per
|
|
10
|
+
// logical dialog instance (rename, confirm, prompt, preview), NOT a monotonic
|
|
11
|
+
// counter that advances on every render and leaves the old aria-labelledby
|
|
12
|
+
// reference dangling. A WeakMap keyed on the options object would not survive
|
|
13
|
+
// re-renders, so we use a short random suffix minted ONCE per Modal() call
|
|
14
|
+
// inside the function body — the closure keeps it stable for that render tree.
|
|
15
|
+
// _modalSeq is retained only for external callers that may import it; it is no
|
|
16
|
+
// longer used internally.
|
|
10
17
|
let _modalSeq = 0;
|
|
11
18
|
|
|
12
19
|
function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
|
|
@@ -19,13 +26,6 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
19
26
|
const modal = el.querySelector('.ds-modal');
|
|
20
27
|
if (!modal) return;
|
|
21
28
|
|
|
22
|
-
// Focus trap: handle Tab key to cycle focus within modal
|
|
23
|
-
const focusables = modal.querySelectorAll(
|
|
24
|
-
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
25
|
-
);
|
|
26
|
-
const firstFocusable = focusables[0];
|
|
27
|
-
const lastFocusable = focusables[focusables.length - 1];
|
|
28
|
-
|
|
29
29
|
const handleKeydown = (e) => {
|
|
30
30
|
// Escape closes the modal — unless a mutation is in flight (the live
|
|
31
31
|
// busy state is read off the data-busy attribute, which re-renders;
|
|
@@ -36,12 +36,19 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
36
36
|
if (onClose) onClose();
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
-
// Tab
|
|
39
|
+
// Focus trap: re-query focusables on each Tab press so that buttons
|
|
40
|
+
// disabled mid-flight (busy state) are excluded from the cycle and
|
|
41
|
+
// do not break tab navigation.
|
|
40
42
|
if (e.key === 'Tab') {
|
|
43
|
+
const focusables = modal.querySelectorAll(
|
|
44
|
+
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
45
|
+
);
|
|
41
46
|
if (focusables.length === 0) {
|
|
42
47
|
e.preventDefault();
|
|
43
48
|
return;
|
|
44
49
|
}
|
|
50
|
+
const firstFocusable = focusables[0];
|
|
51
|
+
const lastFocusable = focusables[focusables.length - 1];
|
|
45
52
|
if (e.shiftKey) {
|
|
46
53
|
if (document.activeElement === firstFocusable) {
|
|
47
54
|
e.preventDefault();
|
|
@@ -127,7 +134,11 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
127
134
|
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
|
|
128
135
|
// Give the head a stable id so the dialog can point aria-labelledby at it,
|
|
129
136
|
// exposing the title as the dialog's accessible name to screen readers.
|
|
130
|
-
|
|
137
|
+
// The id is minted once per Modal() call with a short random suffix so it
|
|
138
|
+
// stays constant across re-renders of the same dialog instance — an
|
|
139
|
+
// incrementing counter advances on every render, leaving the previous
|
|
140
|
+
// aria-labelledby reference pointing at a now-absent element.
|
|
141
|
+
const headId = head != null ? ('ds-modal-head-' + Math.random().toString(36).slice(2, 8)) : null;
|
|
131
142
|
return Backdrop({
|
|
132
143
|
onClose,
|
|
133
144
|
kind,
|
package/src/components/files.js
CHANGED
|
@@ -462,7 +462,7 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
|
|
|
462
462
|
return h('div', {
|
|
463
463
|
class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
|
|
464
464
|
ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
|
|
465
|
-
ondragleave: (e) => { onDragLeave && onDragLeave(e); },
|
|
465
|
+
ondragleave: (e) => { if (!e.currentTarget.contains(e.relatedTarget)) { onDragLeave && onDragLeave(e); } },
|
|
466
466
|
ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
|
|
467
467
|
},
|
|
468
468
|
h('div', { class: 'ds-dropzone-inner' },
|
|
@@ -498,12 +498,9 @@ export function UploadProgress({ items = [], onDismiss } = {}) {
|
|
|
498
498
|
return h('div', {
|
|
499
499
|
key: it.name + i,
|
|
500
500
|
class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
|
|
501
|
-
role: '
|
|
502
|
-
'aria-valuenow': String(Math.max(0, Math.min(100, it.pct || 0))),
|
|
503
|
-
'aria-valuemin': '0',
|
|
504
|
-
'aria-valuemax': '100',
|
|
501
|
+
role: 'status',
|
|
505
502
|
'aria-label': `${it.name}: ${status}`,
|
|
506
|
-
'aria-
|
|
503
|
+
'aria-live': 'polite'
|
|
507
504
|
},
|
|
508
505
|
h('span', { class: 'ds-upload-name' }, it.name),
|
|
509
506
|
h('span', { class: 'ds-upload-bar' },
|
|
@@ -132,6 +132,7 @@ export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', c
|
|
|
132
132
|
const el = document.createElement('div');
|
|
133
133
|
el.className = 'ds-popover';
|
|
134
134
|
el.setAttribute('role', 'dialog');
|
|
135
|
+
el.setAttribute('aria-modal', 'true');
|
|
135
136
|
if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
|
|
136
137
|
el.tabIndex = -1;
|
|
137
138
|
document.body.appendChild(el);
|
|
@@ -246,7 +247,7 @@ function _clampToViewport(x, y, w, h, margin = CLAMP_MARGIN) {
|
|
|
246
247
|
|
|
247
248
|
// Tab focus trap for a dialog root — keeps Tab/Shift+Tab cycling inside `el`.
|
|
248
249
|
// Call from an onkeydown handler; returns true if it handled the event.
|
|
249
|
-
function
|
|
250
|
+
export function trapTab(el, e) {
|
|
250
251
|
if (e.key !== 'Tab') return false;
|
|
251
252
|
const nodes = el.querySelectorAll(FOCUSABLE_SEL);
|
|
252
253
|
if (!nodes.length) { e.preventDefault(); return true; }
|
|
@@ -299,6 +300,7 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
299
300
|
const hint = it.hint != null ? it.hint : (it.shortcut != null ? it.shortcut : null);
|
|
300
301
|
out.push(h('button', {
|
|
301
302
|
type: 'button', role: 'option',
|
|
303
|
+
id: 'ov-cmd-item-' + idx,
|
|
302
304
|
'data-idx': String(idx),
|
|
303
305
|
'aria-selected': idx === active ? 'true' : 'false',
|
|
304
306
|
class: 'ov-cmd-item' + (idx === active ? ' is-active' : ''),
|
|
@@ -330,6 +332,7 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
330
332
|
filtered.length ? rowsFor(filtered) : h('div', { class: 'ov-cmd-empty' }, 'No results')));
|
|
331
333
|
const sel = listEl.querySelector('.ov-cmd-item.is-active');
|
|
332
334
|
if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
|
|
335
|
+
if (inputEl) inputEl.setAttribute('aria-activedescendant', filtered.length ? 'ov-cmd-item-' + active : '');
|
|
333
336
|
};
|
|
334
337
|
|
|
335
338
|
const onKey = (e) => {
|
|
@@ -349,11 +352,15 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
349
352
|
});
|
|
350
353
|
},
|
|
351
354
|
},
|
|
352
|
-
h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-label': 'Command palette', onkeydown: onKey },
|
|
355
|
+
h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette', onkeydown: onKey },
|
|
353
356
|
h('input', {
|
|
354
357
|
type: 'text', class: 'ov-cmd-input', placeholder: 'Type a command…',
|
|
355
358
|
'aria-label': 'command search',
|
|
359
|
+
role: 'combobox',
|
|
360
|
+
'aria-autocomplete': 'list',
|
|
361
|
+
'aria-expanded': 'true',
|
|
356
362
|
'aria-controls': 'ov-cmd-list',
|
|
363
|
+
'aria-activedescendant': '',
|
|
357
364
|
oninput: (e) => { filterText = e.target.value; active = 0; renderInner(); },
|
|
358
365
|
ref: (el) => { if (!el || el._ovCmdIn) return; el._ovCmdIn = true; inputEl = el; queueMicrotask(() => el.focus()); },
|
|
359
366
|
}),
|
|
@@ -368,14 +375,38 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
368
375
|
// per-emoji <button> labels below. This is intentional product content, not
|
|
369
376
|
// decorative chrome.
|
|
370
377
|
const EMOJI_CATEGORIES = [
|
|
371
|
-
{ id: 'smileys', label: '😀', emoji: [
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
378
|
+
{ id: 'smileys', label: '😀', emoji: [
|
|
379
|
+
['😀', 'grinning smile'], ['😁', 'grinning smile happy'], ['😂', 'joy tears laugh'], ['🤣', 'rofl laugh'],
|
|
380
|
+
['😊', 'smile blush happy'], ['😍', 'heart eyes love'], ['😘', 'kiss'], ['😎', 'cool sunglasses'],
|
|
381
|
+
['🤔', 'thinking'], ['😅', 'sweat smile'], ['😉', 'wink'], ['🙂', 'smile slight'],
|
|
382
|
+
['😇', 'angel innocent'], ['🥳', 'party'], ['😴', 'sleep'], ['🤩', 'starstruck'],
|
|
383
|
+
['😜', 'wink tongue'], ['😢', 'cry sad'], ['😭', 'sob cry'], ['😡', 'angry mad'],
|
|
384
|
+
['😱', 'scream shock'], ['🥺', 'pleading'], ['😤', 'huff'], ['😬', 'grimace'],
|
|
385
|
+
] },
|
|
386
|
+
{ id: 'gestures', label: '👍', emoji: [
|
|
387
|
+
['👍', 'thumbsup yes good'], ['👎', 'thumbsdown no bad'], ['👌', 'ok'], ['✌️', 'peace'],
|
|
388
|
+
['🤞', 'fingers crossed'], ['🙏', 'pray thanks'], ['👏', 'clap'], ['🙌', 'raised hands'],
|
|
389
|
+
['💪', 'muscle strong'], ['👀', 'eyes look'], ['🤝', 'handshake'], ['✋', 'hand stop'],
|
|
390
|
+
['🤙', 'call'], ['👋', 'wave hi bye'], ['🤟', 'love you'], ['☝️', 'point up'],
|
|
391
|
+
] },
|
|
392
|
+
{ id: 'hearts', label: '❤️', emoji: [
|
|
393
|
+
['❤️', 'heart love red'], ['🧡', 'heart orange'], ['💛', 'heart yellow'], ['💚', 'heart green'],
|
|
394
|
+
['💙', 'heart blue'], ['💜', 'heart purple'], ['🖤', 'heart black'], ['🤍', 'heart white'],
|
|
395
|
+
['💔', 'broken heart'], ['💕', 'hearts'], ['💖', 'sparkling heart'], ['💗', 'growing heart'],
|
|
396
|
+
] },
|
|
397
|
+
{ id: 'symbols', label: '✅', emoji: [
|
|
398
|
+
['🔥', 'fire lit'], ['💯', 'hundred'], ['✅', 'check yes done'], ['❌', 'cross no'],
|
|
399
|
+
['⭐', 'star'], ['🎉', 'party tada'], ['🎊', 'confetti'], ['✨', 'sparkles'],
|
|
400
|
+
['💡', 'idea lightbulb'], ['⚡', 'zap lightning'], ['💢', 'anger'], ['💀', 'skull dead'],
|
|
401
|
+
['🚀', 'rocket launch'], ['🏆', 'trophy win'],
|
|
402
|
+
] },
|
|
375
403
|
];
|
|
404
|
+
const ALL_EMOJI = EMOJI_CATEGORIES.flatMap((c) => c.emoji);
|
|
376
405
|
|
|
377
406
|
// EmojiPicker — fixed popover near (anchorX, anchorY) with category tabs + grid.
|
|
378
|
-
|
|
407
|
+
// `query`, when non-empty, filters across all categories by name/keyword
|
|
408
|
+
// substring match (case-insensitive) instead of showing the active tab.
|
|
409
|
+
export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose, query = '' } = {}) {
|
|
379
410
|
if (!open) return null;
|
|
380
411
|
let cat = EMOJI_CATEGORIES[0].id;
|
|
381
412
|
let rootEl = null, gridEl = null;
|
|
@@ -383,28 +414,44 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
|
|
|
383
414
|
|
|
384
415
|
const renderGrid = () => {
|
|
385
416
|
if (!gridEl) return;
|
|
386
|
-
const
|
|
417
|
+
const q = (query || '').trim().toLowerCase();
|
|
418
|
+
const cells = q
|
|
419
|
+
? ALL_EMOJI.filter(([, name]) => name.toLowerCase().includes(q))
|
|
420
|
+
: (EMOJI_CATEGORIES.find(x => x.id === cat) || EMOJI_CATEGORIES[0]).emoji;
|
|
387
421
|
webjsx.applyDiff(gridEl, h('div', { class: 'ov-emoji-grid-inner' },
|
|
388
|
-
|
|
389
|
-
type: 'button', class: 'ov-emoji-cell', 'aria-label': ch,
|
|
422
|
+
cells.length ? cells.map(([ch, name]) => h('button', {
|
|
423
|
+
type: 'button', class: 'ov-emoji-cell', 'aria-label': name || ch, title: name || ch,
|
|
390
424
|
onclick: () => { if (onSelect) onSelect(ch); },
|
|
391
|
-
}, ch))));
|
|
425
|
+
}, ch)) : h('div', { class: 'ov-emoji-empty' }, 'no emoji found')));
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const tabNavKey = (e) => {
|
|
429
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
430
|
+
const tabs = rootEl ? [...rootEl.querySelectorAll('.ov-emoji-tab')] : [];
|
|
431
|
+
if (!tabs.length) return;
|
|
432
|
+
const idx = tabs.indexOf(document.activeElement);
|
|
433
|
+
if (idx < 0) return;
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
|
|
436
|
+
tabs[next].focus();
|
|
437
|
+
tabs[next].click();
|
|
392
438
|
};
|
|
393
439
|
|
|
394
440
|
return h('div', {
|
|
395
|
-
class: 'ov-emoji-root', role: 'dialog', 'aria-label': 'Emoji picker',
|
|
441
|
+
class: 'ov-emoji-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Emoji picker',
|
|
396
442
|
tabindex: '-1',
|
|
397
|
-
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl)
|
|
443
|
+
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } tabNavKey(e); if (rootEl) trapTab(rootEl, e); },
|
|
398
444
|
ref: (el) => {
|
|
399
445
|
if (!el) { if (rootEl && rootEl._ovEmojiCleanup) rootEl._ovEmojiCleanup(); return; }
|
|
400
446
|
if (el._ovEmoji) return; el._ovEmoji = true; rootEl = el;
|
|
401
447
|
el._ovEmojiCleanup = _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW: 260, fallbackH: 240, close });
|
|
402
448
|
},
|
|
403
449
|
},
|
|
404
|
-
h('div', { class: 'ov-emoji-tabs', role: 'tablist' },
|
|
450
|
+
(query || '').trim() ? null : h('div', { class: 'ov-emoji-tabs', role: 'tablist' },
|
|
405
451
|
...EMOJI_CATEGORIES.map((c) => h('button', {
|
|
406
452
|
type: 'button', class: 'ov-emoji-tab', role: 'tab',
|
|
407
453
|
'aria-selected': c.id === cat ? 'true' : 'false',
|
|
454
|
+
'aria-controls': 'ov-emoji-panel',
|
|
408
455
|
onclick: (e) => {
|
|
409
456
|
cat = c.id;
|
|
410
457
|
const tabs = rootEl.querySelectorAll('.ov-emoji-tab');
|
|
@@ -413,7 +460,9 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
|
|
|
413
460
|
renderGrid();
|
|
414
461
|
},
|
|
415
462
|
}, c.label))),
|
|
416
|
-
h('div', {
|
|
463
|
+
h('div', {
|
|
464
|
+
class: 'ov-emoji-grid', id: 'ov-emoji-panel', role: 'tabpanel',
|
|
465
|
+
'aria-label': EMOJI_CATEGORIES.find(c => c.id === cat)?.label || EMOJI_CATEGORIES[0].label,
|
|
417
466
|
ref: (el) => { if (!el) return; gridEl = el; queueMicrotask(renderGrid); } })
|
|
418
467
|
);
|
|
419
468
|
}
|
|
@@ -495,8 +544,8 @@ export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY
|
|
|
495
544
|
};
|
|
496
545
|
|
|
497
546
|
return h('div', {
|
|
498
|
-
class: 'ov-set-root', role: 'dialog', 'aria-label': String(title), tabindex: '-1',
|
|
499
|
-
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl)
|
|
547
|
+
class: 'ov-set-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': String(title), tabindex: '-1',
|
|
548
|
+
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) trapTab(rootEl, e); },
|
|
500
549
|
ref: (el) => {
|
|
501
550
|
if (!el) { if (rootEl && rootEl._ovSetCleanup) rootEl._ovSetCleanup(); return; }
|
|
502
551
|
if (el._ovSet) return; el._ovSet = true; rootEl = el;
|
|
@@ -570,15 +619,31 @@ export function AuthModal({ mode = 'extension', error = '', busy = false, open =
|
|
|
570
619
|
h('h2', { class: 'ov-auth-title' }, 'Sign in'),
|
|
571
620
|
h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, Icon('x'))
|
|
572
621
|
),
|
|
573
|
-
h('div', { class: 'ov-auth-tabs', role: 'tablist'
|
|
622
|
+
h('div', { class: 'ov-auth-tabs', role: 'tablist',
|
|
623
|
+
onkeydown: (e) => {
|
|
624
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
625
|
+
const panel = e.currentTarget.closest('.ov-auth-panel');
|
|
626
|
+
const tabs = panel ? [...panel.querySelectorAll('.ov-auth-tab')] : [];
|
|
627
|
+
if (!tabs.length) return;
|
|
628
|
+
const idx = tabs.indexOf(document.activeElement);
|
|
629
|
+
if (idx < 0) return;
|
|
630
|
+
e.preventDefault();
|
|
631
|
+
const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
|
|
632
|
+
tabs[next].focus();
|
|
633
|
+
onModeChange && onModeChange(modes[next].id);
|
|
634
|
+
},
|
|
635
|
+
},
|
|
574
636
|
...modes.map(m => h('button', {
|
|
575
637
|
type: 'button', role: 'tab', key: 'am-' + m.id,
|
|
638
|
+
id: 'ov-auth-tab-' + m.id,
|
|
576
639
|
class: 'ov-auth-tab' + (m.id === mode ? ' is-active' : ''),
|
|
577
640
|
'aria-selected': m.id === mode ? 'true' : 'false',
|
|
641
|
+
'aria-controls': 'ov-auth-panel',
|
|
578
642
|
onclick: () => onModeChange && onModeChange(m.id),
|
|
579
643
|
}, m.label))
|
|
580
644
|
),
|
|
581
|
-
h('div', { class: 'ov-auth-body'
|
|
645
|
+
h('div', { class: 'ov-auth-body', id: 'ov-auth-panel', role: 'tabpanel',
|
|
646
|
+
'aria-labelledby': 'ov-auth-tab-' + mode }, ...body()),
|
|
582
647
|
error ? h('div', { class: 'ov-auth-error', role: 'alert' }, String(error)) : null
|
|
583
648
|
)
|
|
584
649
|
);
|
|
@@ -36,14 +36,16 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
36
36
|
onSelect, onNew, newLabel = 'New chat',
|
|
37
37
|
emptyText = 'No conversations yet', loading = false, error = null,
|
|
38
38
|
loadingText = 'Loading conversations…' } = {}) {
|
|
39
|
-
const rowFor = (s, i) => h('
|
|
39
|
+
const rowFor = (s, i) => h('div', {
|
|
40
40
|
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
41
41
|
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
42
42
|
key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
|
|
43
|
-
|
|
43
|
+
role: 'option',
|
|
44
|
+
tabindex: s.sid === selected ? '0' : '-1',
|
|
44
45
|
class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
|
|
45
|
-
'aria-
|
|
46
|
+
'aria-selected': s.sid === selected ? 'true' : 'false',
|
|
46
47
|
onclick: () => onSelect && onSelect(s),
|
|
48
|
+
onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
|
|
47
49
|
},
|
|
48
50
|
// Positional children must NOT mix keyed VElements with null/strings (webjsx
|
|
49
51
|
// applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
|
|
@@ -57,7 +59,14 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
57
59
|
].filter(Boolean)),
|
|
58
60
|
h('span', { class: 'ds-session-meta' }, [
|
|
59
61
|
s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
|
|
60
|
-
|
|
62
|
+
// Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
|
|
63
|
+
// SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
|
|
64
|
+
// pinned to a "Running" rail group reads the same stuck-vs-busy signal it
|
|
65
|
+
// does there rather than only a boolean live dot. Falls back to the plain
|
|
66
|
+
// running dot when no status is supplied (existing callers unaffected).
|
|
67
|
+
s.status
|
|
68
|
+
? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
|
|
69
|
+
: s.running
|
|
61
70
|
? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
|
|
62
71
|
: (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
|
|
63
72
|
].filter(Boolean)));
|
|
@@ -84,11 +93,11 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
84
93
|
const bySid = new Map(sessions.map((s) => [s.sid, s]));
|
|
85
94
|
inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
|
|
86
95
|
h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
|
|
87
|
-
h('div', { key: 'gr', class: 'ds-session-group-rows', role: '
|
|
96
|
+
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
|
|
88
97
|
} else {
|
|
89
98
|
inner = sessions.map(rowFor);
|
|
90
99
|
}
|
|
91
|
-
const body = h('div', { key: 'body', class: 'ds-session-list', role: '
|
|
100
|
+
const body = h('div', { key: 'body', class: 'ds-session-list', role: 'listbox', 'aria-label': caption || 'Conversations' }, ...inner);
|
|
92
101
|
|
|
93
102
|
return h('div', { class: 'ds-sessions' },
|
|
94
103
|
h('div', { key: 'head', class: 'ds-session-head' },
|