@aparte/core 0.16.7 → 0.16.9
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/dist/client/aparte-client.d.ts +57 -2
- package/dist/client/aparte-client.d.ts.map +1 -1
- package/dist/client/lifecycle-events.d.ts +8 -1
- package/dist/client/lifecycle-events.d.ts.map +1 -1
- package/dist/client/stream-adapter.d.ts +8 -0
- package/dist/client/stream-adapter.d.ts.map +1 -1
- package/dist/components/bubble/aparte-chat-bubble.d.ts +39 -18
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
- package/dist/components/chat/aparte-chat.d.ts +32 -25
- package/dist/components/chat/aparte-chat.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-action.d.ts +1 -1
- package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
- package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
- package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-cancel.d.ts +2 -2
- package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-input.d.ts +6 -6
- package/dist/components/composer/aparte-composer-send.d.ts +3 -3
- package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer.d.ts +9 -2
- package/dist/components/composer/aparte-composer.d.ts.map +1 -1
- package/dist/components/context/aparte-context.d.ts +0 -1
- package/dist/components/context/aparte-context.d.ts.map +1 -1
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +11 -6
- package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
- package/dist/components/elicitation/aparte-elicitation.d.ts +20 -18
- package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +11 -3
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
- package/dist/components/sidebar/aparte-sidebar.d.ts +16 -2
- package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
- package/dist/components/split/aparte-split.d.ts +9 -4
- package/dist/components/split/aparte-split.d.ts.map +1 -1
- package/dist/components/status/aparte-chat-status.d.ts +44 -5
- package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts +27 -4
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
- package/dist/config/icon-provider.d.ts +6 -0
- package/dist/config/icon-provider.d.ts.map +1 -1
- package/dist/config/locale.d.ts +3 -1
- package/dist/config/locale.d.ts.map +1 -1
- package/dist/custom-elements.json +13010 -12737
- package/dist/{dialog-triggers-Dw_DIlQ7.js → dialog-triggers-CApC7K1I.js} +119 -44
- package/dist/dialog-triggers-CApC7K1I.js.map +1 -0
- package/dist/elicitation/panel.d.ts.map +1 -1
- package/dist/generated/element-attributes.d.ts +13 -3
- package/dist/generated/element-attributes.d.ts.map +1 -1
- package/dist/{glyphs-DQTeOj4w.js → glyphs-C8z2oJUg.js} +36 -27
- package/dist/{glyphs-DQTeOj4w.js.map → glyphs-C8z2oJUg.js.map} +1 -1
- package/dist/icons/extended.d.ts +0 -6
- package/dist/icons/extended.d.ts.map +1 -1
- package/dist/icons/glyphs.d.ts +13 -4
- package/dist/icons/glyphs.d.ts.map +1 -1
- package/dist/icons.js +28 -31
- package/dist/icons.js.map +1 -1
- package/dist/index.css +946 -386
- package/dist/index.js +321 -89
- package/dist/index.js.map +1 -1
- package/dist/index.node.js +1 -1
- package/dist/interop/element-props.d.ts.map +1 -1
- package/dist/primitives/icon/aparte-icon.d.ts +1 -1
- package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts +2 -3
- package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -1
- package/dist/primitives/select/aparte-optgroup.d.ts +1 -1
- package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
- package/dist/primitives/select/aparte-option.d.ts +4 -4
- package/dist/primitives/select/aparte-option.d.ts.map +1 -1
- package/dist/primitives/select/aparte-select.d.ts +43 -12
- package/dist/primitives/select/aparte-select.d.ts.map +1 -1
- package/dist/renderers/segments/code.d.ts.map +1 -1
- package/dist/renderers/segments/error.d.ts.map +1 -1
- package/dist/renderers/segments/thinking.d.ts.map +1 -1
- package/dist/renderers/segments/tool-call.d.ts.map +1 -1
- package/dist/types/events.d.ts +4 -2
- package/dist/types/events.d.ts.map +1 -1
- package/package.json +2 -2
- package/dist/dialog-triggers-Dw_DIlQ7.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-
|
|
2
|
-
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-
|
|
1
|
+
import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-CApC7K1I.js";
|
|
2
|
+
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-CApC7K1I.js";
|
|
3
|
+
function presenceOn(value) {
|
|
4
|
+
return value === "" ? true : Boolean(value);
|
|
5
|
+
}
|
|
3
6
|
class AparteOption extends HTMLElement {
|
|
4
7
|
static get observedAttributes() {
|
|
5
8
|
return ["value", "disabled", "selected", "data-status"];
|
|
@@ -34,21 +37,13 @@ class AparteOption extends HTMLElement {
|
|
|
34
37
|
return this.hasAttribute("disabled");
|
|
35
38
|
}
|
|
36
39
|
set disabled(val) {
|
|
37
|
-
|
|
38
|
-
this.setAttribute("disabled", "");
|
|
39
|
-
} else {
|
|
40
|
-
this.removeAttribute("disabled");
|
|
41
|
-
}
|
|
40
|
+
this.toggleAttribute("disabled", presenceOn(val));
|
|
42
41
|
}
|
|
43
42
|
get selected() {
|
|
44
43
|
return this.hasAttribute("selected");
|
|
45
44
|
}
|
|
46
45
|
set selected(val) {
|
|
47
|
-
|
|
48
|
-
this.setAttribute("selected", "");
|
|
49
|
-
} else {
|
|
50
|
-
this.removeAttribute("selected");
|
|
51
|
-
}
|
|
46
|
+
this.toggleAttribute("selected", presenceOn(val));
|
|
52
47
|
}
|
|
53
48
|
_updateAriaSelected() {
|
|
54
49
|
this.setAttribute("aria-selected", this.selected ? "true" : "false");
|
|
@@ -104,18 +99,13 @@ class AparteOptgroup extends HTMLElement {
|
|
|
104
99
|
return this.hasAttribute("collapsed");
|
|
105
100
|
}
|
|
106
101
|
set collapsed(val) {
|
|
107
|
-
|
|
108
|
-
this.setAttribute("collapsed", "");
|
|
109
|
-
} else {
|
|
110
|
-
this.removeAttribute("collapsed");
|
|
111
|
-
}
|
|
102
|
+
this.toggleAttribute("collapsed", presenceOn(val));
|
|
112
103
|
}
|
|
113
104
|
get loading() {
|
|
114
105
|
return this.hasAttribute("loading");
|
|
115
106
|
}
|
|
116
107
|
set loading(val) {
|
|
117
|
-
|
|
118
|
-
else this.removeAttribute("loading");
|
|
108
|
+
this.toggleAttribute("loading", presenceOn(val));
|
|
119
109
|
}
|
|
120
110
|
_render() {
|
|
121
111
|
if (this.label) {
|
|
@@ -152,7 +142,9 @@ class AparteOptgroup extends HTMLElement {
|
|
|
152
142
|
if (!loader) {
|
|
153
143
|
loader = document.createElement("div");
|
|
154
144
|
loader.className = "aparte-optgroup-loader";
|
|
155
|
-
|
|
145
|
+
const spinner = document.createElement("span");
|
|
146
|
+
spinner.className = "aparte-spinner-small";
|
|
147
|
+
loader.replaceChildren(spinner, ` ${resolveConfig(this).t("loading") || "Loading…"}`);
|
|
156
148
|
this.appendChild(loader);
|
|
157
149
|
}
|
|
158
150
|
} else if (loader) {
|
|
@@ -211,14 +203,16 @@ class AparteSelect extends HTMLElement {
|
|
|
211
203
|
_boundHandleDocumentClick = this._handleDocumentClick.bind(this);
|
|
212
204
|
_boundHandleKeydown = this._handleKeydown.bind(this);
|
|
213
205
|
static get observedAttributes() {
|
|
214
|
-
return ["value", "placeholder", "disabled", "
|
|
206
|
+
return ["value", "placeholder", "disabled", "searchable", "open"];
|
|
215
207
|
}
|
|
216
208
|
connectedCallback() {
|
|
217
209
|
this._value = this.getAttribute("value") || "";
|
|
218
|
-
|
|
210
|
+
const wantsOpen = this.hasAttribute("open") && !this._isOpen;
|
|
219
211
|
this._render();
|
|
212
|
+
this._applyDisabled();
|
|
220
213
|
this._setupEventListeners();
|
|
221
214
|
this._setupMutationObserver();
|
|
215
|
+
if (wantsOpen) this._openDropdown();
|
|
222
216
|
}
|
|
223
217
|
disconnectedCallback() {
|
|
224
218
|
this.removeEventListener("click", this._boundHandleOptionClick);
|
|
@@ -233,14 +227,19 @@ class AparteSelect extends HTMLElement {
|
|
|
233
227
|
this._updateTriggerLabel();
|
|
234
228
|
}
|
|
235
229
|
if (name === "open") {
|
|
236
|
-
|
|
237
|
-
if (this._isOpen) {
|
|
238
|
-
this.
|
|
239
|
-
this.
|
|
240
|
-
} else {
|
|
241
|
-
this._dropdown?.setAttribute("hidden", "");
|
|
230
|
+
const wanted = this.hasAttribute("open");
|
|
231
|
+
if (wanted !== this._isOpen) {
|
|
232
|
+
if (wanted) this._openDropdown();
|
|
233
|
+
else this._closeDropdown();
|
|
242
234
|
}
|
|
243
235
|
}
|
|
236
|
+
if (name === "placeholder") {
|
|
237
|
+
this._updateTriggerLabel();
|
|
238
|
+
}
|
|
239
|
+
if (name === "disabled") {
|
|
240
|
+
this._applyDisabled();
|
|
241
|
+
if (!this.hasAttribute("disabled") && this.hasAttribute("open")) this._openDropdown();
|
|
242
|
+
}
|
|
244
243
|
}
|
|
245
244
|
// ─────────────────────────────────────────────────────────────────────────
|
|
246
245
|
// Public API
|
|
@@ -260,11 +259,7 @@ class AparteSelect extends HTMLElement {
|
|
|
260
259
|
return this._isOpen;
|
|
261
260
|
}
|
|
262
261
|
set open(val) {
|
|
263
|
-
|
|
264
|
-
this.setAttribute("open", "");
|
|
265
|
-
} else {
|
|
266
|
-
this.removeAttribute("open");
|
|
267
|
-
}
|
|
262
|
+
this.toggleAttribute("open", presenceOn(val));
|
|
268
263
|
}
|
|
269
264
|
// ─────────────────────────────────────────────────────────────────────────
|
|
270
265
|
// Rendering
|
|
@@ -281,7 +276,7 @@ class AparteSelect extends HTMLElement {
|
|
|
281
276
|
const trigger = document.createElement("div");
|
|
282
277
|
trigger.className = "aparte-select-trigger";
|
|
283
278
|
trigger.setAttribute("tabindex", "0");
|
|
284
|
-
trigger.setAttribute("role", "combobox");
|
|
279
|
+
trigger.setAttribute("role", searchable ? "button" : "combobox");
|
|
285
280
|
trigger.setAttribute("aria-haspopup", "listbox");
|
|
286
281
|
trigger.setAttribute("aria-expanded", "false");
|
|
287
282
|
trigger.setAttribute("aria-label", this.getAttribute("aria-label") || placeholder);
|
|
@@ -301,12 +296,17 @@ class AparteSelect extends HTMLElement {
|
|
|
301
296
|
const dropdown = document.createElement("div");
|
|
302
297
|
dropdown.className = "aparte-select-dropdown";
|
|
303
298
|
dropdown.hidden = !this._isOpen;
|
|
299
|
+
let searchInput = null;
|
|
304
300
|
if (searchable) {
|
|
305
|
-
|
|
301
|
+
searchInput = document.createElement("input");
|
|
306
302
|
searchInput.type = "text";
|
|
307
303
|
searchInput.className = "aparte-select-search";
|
|
308
304
|
searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
|
|
309
305
|
searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
|
|
306
|
+
searchInput.setAttribute("role", "combobox");
|
|
307
|
+
searchInput.setAttribute("aria-haspopup", "listbox");
|
|
308
|
+
searchInput.setAttribute("aria-autocomplete", "list");
|
|
309
|
+
searchInput.setAttribute("aria-expanded", String(this._isOpen));
|
|
310
310
|
dropdown.appendChild(searchInput);
|
|
311
311
|
}
|
|
312
312
|
const optionsContainer = document.createElement("div");
|
|
@@ -315,6 +315,7 @@ class AparteSelect extends HTMLElement {
|
|
|
315
315
|
optionsContainer.setAttribute("aria-label", trigger.getAttribute("aria-label") ?? placeholder);
|
|
316
316
|
optionsContainer.id = this.id ? `${this.id}-listbox` : `aparte-listbox-${++AparteSelect._listboxSeq}`;
|
|
317
317
|
trigger.setAttribute("aria-controls", optionsContainer.id);
|
|
318
|
+
searchInput?.setAttribute("aria-controls", optionsContainer.id);
|
|
318
319
|
slottedChildren.forEach((child) => {
|
|
319
320
|
if (child.tagName === "APARTE-OPTION" || child.tagName === "APARTE-OPTGROUP") {
|
|
320
321
|
optionsContainer.appendChild(child);
|
|
@@ -463,6 +464,20 @@ class AparteSelect extends HTMLElement {
|
|
|
463
464
|
const base = this._activeIndex < 0 ? delta > 0 ? -1 : 0 : this._activeIndex;
|
|
464
465
|
this._setActive(base + delta);
|
|
465
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* The element that holds DOM focus while the list is being driven, and therefore
|
|
469
|
+
* the one that must carry the combobox state a reader follows: the filter field
|
|
470
|
+
* when the select is `searchable` (it is focused on open), the trigger otherwise.
|
|
471
|
+
*/
|
|
472
|
+
_activeHost() {
|
|
473
|
+
return this._searchInput ?? this._trigger;
|
|
474
|
+
}
|
|
475
|
+
/** `aria-expanded` belongs to the combobox — write it wherever the combobox is. */
|
|
476
|
+
_setExpanded(open) {
|
|
477
|
+
const flag = String(open);
|
|
478
|
+
this._trigger?.setAttribute("aria-expanded", flag);
|
|
479
|
+
this._searchInput?.setAttribute("aria-expanded", flag);
|
|
480
|
+
}
|
|
466
481
|
/** Highlight the option at `index` (clamped) and point aria-activedescendant at it. */
|
|
467
482
|
_setActive(index) {
|
|
468
483
|
const all = this.querySelectorAll("aparte-option");
|
|
@@ -470,7 +485,7 @@ class AparteSelect extends HTMLElement {
|
|
|
470
485
|
const opts = this._visibleOptions();
|
|
471
486
|
if (opts.length === 0) {
|
|
472
487
|
this._activeIndex = -1;
|
|
473
|
-
this.
|
|
488
|
+
this._activeHost()?.removeAttribute("aria-activedescendant");
|
|
474
489
|
return;
|
|
475
490
|
}
|
|
476
491
|
const clamped = Math.max(0, Math.min(index, opts.length - 1));
|
|
@@ -478,13 +493,13 @@ class AparteSelect extends HTMLElement {
|
|
|
478
493
|
const active = opts[clamped];
|
|
479
494
|
if (!active.id) active.id = `aparte-option-${++AparteSelect._optIdSeq}`;
|
|
480
495
|
active.setAttribute("data-active", "");
|
|
481
|
-
this.
|
|
496
|
+
this._activeHost()?.setAttribute("aria-activedescendant", active.id);
|
|
482
497
|
active.scrollIntoView?.({ block: "nearest" });
|
|
483
498
|
}
|
|
484
499
|
/** Clear the keyboard highlight (on close). */
|
|
485
500
|
_clearActive() {
|
|
486
501
|
this._activeIndex = -1;
|
|
487
|
-
this.
|
|
502
|
+
this._activeHost()?.removeAttribute("aria-activedescendant");
|
|
488
503
|
this.querySelectorAll("aparte-option").forEach((o) => o.removeAttribute("data-active"));
|
|
489
504
|
}
|
|
490
505
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -498,10 +513,11 @@ class AparteSelect extends HTMLElement {
|
|
|
498
513
|
}
|
|
499
514
|
}
|
|
500
515
|
_openDropdown() {
|
|
516
|
+
if (this._isOpen) return;
|
|
501
517
|
if (this.hasAttribute("disabled")) return;
|
|
502
518
|
this._isOpen = true;
|
|
503
519
|
this._dropdown?.removeAttribute("hidden");
|
|
504
|
-
this.
|
|
520
|
+
this._setExpanded(true);
|
|
505
521
|
this.setAttribute("open", "");
|
|
506
522
|
this._updatePosition();
|
|
507
523
|
this._searchInput?.focus();
|
|
@@ -514,7 +530,7 @@ class AparteSelect extends HTMLElement {
|
|
|
514
530
|
this._isOpen = false;
|
|
515
531
|
this._clearActive();
|
|
516
532
|
this._dropdown?.setAttribute("hidden", "");
|
|
517
|
-
this.
|
|
533
|
+
this._setExpanded(false);
|
|
518
534
|
this.removeAttribute("open");
|
|
519
535
|
this.removeAttribute("position");
|
|
520
536
|
if (this._dropdown) {
|
|
@@ -558,6 +574,47 @@ class AparteSelect extends HTMLElement {
|
|
|
558
574
|
this._emitChange(previousValue);
|
|
559
575
|
this._trigger?.focus();
|
|
560
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* The combobox and its listbox are named by the host's `aria-label`, else by the
|
|
579
|
+
* placeholder — the same rule the first render applies, re-applied whenever the
|
|
580
|
+
* placeholder or the selection changes. Written only when different: the mutation
|
|
581
|
+
* observer watches this subtree.
|
|
582
|
+
*
|
|
583
|
+
* A `combobox` is NAMED by that attribute and VALUED by its visible label span, so
|
|
584
|
+
* the name is the control and nothing else. A `button` — the shape a `searchable`
|
|
585
|
+
* trigger takes, since the filter field is then the combobox — is named by its
|
|
586
|
+
* CONTENT, which this very attribute overrides: the searchable trigger announced
|
|
587
|
+
* "Pick a model, button" and never said which model was selected. So the button's
|
|
588
|
+
* name carries both halves, and drops the second when it would only repeat the
|
|
589
|
+
* first (nothing selected: the value IS the placeholder).
|
|
590
|
+
*/
|
|
591
|
+
_updateNames() {
|
|
592
|
+
const placeholder = this.getAttribute("placeholder") || "Select...";
|
|
593
|
+
const name = this.getAttribute("aria-label") || placeholder;
|
|
594
|
+
const list = this.querySelector(".aparte-select-options");
|
|
595
|
+
if (list && list.getAttribute("aria-label") !== name) list.setAttribute("aria-label", name);
|
|
596
|
+
const trigger = this._trigger;
|
|
597
|
+
if (!trigger) return;
|
|
598
|
+
const value = this._getSelectedLabel() || placeholder;
|
|
599
|
+
const wanted = trigger.getAttribute("role") === "button" && value !== name ? `${name}: ${value}` : name;
|
|
600
|
+
if (trigger.getAttribute("aria-label") !== wanted) trigger.setAttribute("aria-label", wanted);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* `disabled` takes the trigger out of the tab order and says so — opacity alone
|
|
604
|
+
* left it focusable and announced as operable. An open dropdown closes.
|
|
605
|
+
*/
|
|
606
|
+
_applyDisabled() {
|
|
607
|
+
const trigger = this._trigger;
|
|
608
|
+
if (!trigger) return;
|
|
609
|
+
const disabled = this.hasAttribute("disabled");
|
|
610
|
+
trigger.setAttribute("tabindex", disabled ? "-1" : "0");
|
|
611
|
+
if (disabled) {
|
|
612
|
+
trigger.setAttribute("aria-disabled", "true");
|
|
613
|
+
if (this._isOpen) this._closeDropdown();
|
|
614
|
+
} else {
|
|
615
|
+
trigger.removeAttribute("aria-disabled");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
561
618
|
_updateTriggerLabel() {
|
|
562
619
|
const placeholder = this.getAttribute("placeholder") || "Select...";
|
|
563
620
|
const labelText = this._trigger?.querySelector(".aparte-select-label-text");
|
|
@@ -577,6 +634,7 @@ class AparteSelect extends HTMLElement {
|
|
|
577
634
|
}));
|
|
578
635
|
}
|
|
579
636
|
}
|
|
637
|
+
this._updateNames();
|
|
580
638
|
const options = this.querySelectorAll("aparte-option");
|
|
581
639
|
options.forEach((opt) => {
|
|
582
640
|
const isSelected = opt.getAttribute("value") === this._value;
|
|
@@ -895,7 +953,7 @@ if (typeof customElements !== "undefined" && !customElements.get("aparte-elicita
|
|
|
895
953
|
}
|
|
896
954
|
class AparteChat extends HTMLElement {
|
|
897
955
|
static get observedAttributes() {
|
|
898
|
-
return ["placeholder", "disabled", "center-empty", "attachments"];
|
|
956
|
+
return ["placeholder", "disabled", "submit-on-enter", "center-empty", "attachments"];
|
|
899
957
|
}
|
|
900
958
|
_observer = null;
|
|
901
959
|
/**
|
|
@@ -908,6 +966,7 @@ class AparteChat extends HTMLElement {
|
|
|
908
966
|
this._render();
|
|
909
967
|
this._forwardAttr("placeholder");
|
|
910
968
|
this._forwardAttr("disabled");
|
|
969
|
+
this._forwardAttr("submit-on-enter");
|
|
911
970
|
this._syncEmptyWatch();
|
|
912
971
|
}
|
|
913
972
|
disconnectedCallback() {
|
|
@@ -1064,6 +1123,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1064
1123
|
_editing = false;
|
|
1065
1124
|
/** The live inline editor (the composer's contenteditable primitive), present only while `_editing`. */
|
|
1066
1125
|
_editInput = null;
|
|
1126
|
+
/**
|
|
1127
|
+
* The `data-action` of the button the editor was opened from, so leaving it can hand
|
|
1128
|
+
* the keyboard back. The STRING, not the node: entering and leaving edit mode rewrite
|
|
1129
|
+
* the action bar's `innerHTML`, so the opener's identity is destroyed twice.
|
|
1130
|
+
*/
|
|
1131
|
+
_editReturnAction = null;
|
|
1067
1132
|
static get observedAttributes() {
|
|
1068
1133
|
return ["data-role", "content", "timestamp", "message-id", "streaming", "name"];
|
|
1069
1134
|
}
|
|
@@ -1187,6 +1252,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1187
1252
|
appendToken(chunk) {
|
|
1188
1253
|
this._content += chunk;
|
|
1189
1254
|
this._updateContent();
|
|
1255
|
+
this._syncCopyAction();
|
|
1190
1256
|
}
|
|
1191
1257
|
/** Set content directly */
|
|
1192
1258
|
setContent(content) {
|
|
@@ -1194,6 +1260,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1194
1260
|
this.setAttribute("content", content);
|
|
1195
1261
|
this._resetMarkdownStream();
|
|
1196
1262
|
this._updateContent();
|
|
1263
|
+
this._syncCopyAction();
|
|
1197
1264
|
}
|
|
1198
1265
|
/**
|
|
1199
1266
|
* Drop the incremental Markdown parser's state.
|
|
@@ -1229,6 +1296,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1229
1296
|
const updated = mergeSegmentUpdate(this._segments[index], updates);
|
|
1230
1297
|
this._segments[index] = updated;
|
|
1231
1298
|
this._applySegmentUpdate(segmentId, updated, updates);
|
|
1299
|
+
this._syncCopyAction();
|
|
1232
1300
|
}
|
|
1233
1301
|
}
|
|
1234
1302
|
/** Append content to a segment */
|
|
@@ -1237,6 +1305,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1237
1305
|
if (segment && "content" in segment) {
|
|
1238
1306
|
segment.content += content;
|
|
1239
1307
|
this._applySegmentUpdate(segmentId, segment, { content: segment.content });
|
|
1308
|
+
this._syncCopyAction();
|
|
1240
1309
|
}
|
|
1241
1310
|
}
|
|
1242
1311
|
/** Get all segments */
|
|
@@ -1266,6 +1335,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1266
1335
|
const el2 = this._segmentsEl?.querySelector(`:scope > [data-segment-id="${cssEscape(segmentId)}"]`);
|
|
1267
1336
|
el2?.remove();
|
|
1268
1337
|
this._updateWaiting();
|
|
1338
|
+
this._syncCopyAction();
|
|
1269
1339
|
}
|
|
1270
1340
|
/** Set attachments (chips shown above message content, user role only) */
|
|
1271
1341
|
setAttachments(attachments) {
|
|
@@ -1420,7 +1490,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1420
1490
|
</div>
|
|
1421
1491
|
<div class="aparte-footer">
|
|
1422
1492
|
<div class="aparte-branch-picker" hidden>
|
|
1423
|
-
<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-prev" aria-label="${escapeAttr(this._cfg.getLocale().previousResponse ?? "Previous response")}"
|
|
1493
|
+
<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-prev" aria-label="${escapeAttr(this._cfg.getLocale().previousResponse ?? "Previous response")}">${this._cfg.getIcon("prevBranch")}</button>
|
|
1424
1494
|
<span class="aparte-branch-label">1 / 1</span>
|
|
1425
1495
|
<!-- The move has to be ANNOUNCED. Pressing the arrows deliberately does not
|
|
1426
1496
|
take focus, so without a live region a screen-reader user gets the new
|
|
@@ -1429,7 +1499,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1429
1499
|
dots, which reads as nothing. No new locale key — the position is
|
|
1430
1500
|
digits, and the buttons beside it already carry translated labels. -->
|
|
1431
1501
|
<span class="aparte-sr-only aparte-branch-status" aria-live="polite"></span>
|
|
1432
|
-
<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-next" aria-label="${escapeAttr(this._cfg.getLocale().nextResponse ?? "Next response")}"
|
|
1502
|
+
<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-branch-next" aria-label="${escapeAttr(this._cfg.getLocale().nextResponse ?? "Next response")}">${this._cfg.getIcon("nextBranch")}</button>
|
|
1433
1503
|
</div>
|
|
1434
1504
|
<div class="aparte-action-bar" role="toolbar" aria-label="${escapeAttr(this._cfg.getLocale().messageActions ?? "Message actions")}"></div>
|
|
1435
1505
|
</div>
|
|
@@ -1589,6 +1659,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1589
1659
|
this._contentEl.style.display = this._segments.length > 0 ? "none" : "";
|
|
1590
1660
|
}
|
|
1591
1661
|
this._reflectError();
|
|
1662
|
+
this._syncCopyAction();
|
|
1592
1663
|
}
|
|
1593
1664
|
/**
|
|
1594
1665
|
* Reflect the error state on the bubble: `data-error` on `.aparte-message` while
|
|
@@ -1644,16 +1715,16 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1644
1715
|
this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
|
|
1645
1716
|
const name = escapeHtml(a4.name);
|
|
1646
1717
|
if (a4.type.startsWith("image/")) {
|
|
1647
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-
|
|
1718
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1648
1719
|
}
|
|
1649
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-
|
|
1720
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1650
1721
|
}).join("");
|
|
1651
1722
|
if (!this._cfg.getHostHandlers().attachmentPreview) return;
|
|
1652
1723
|
this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
|
|
1653
1724
|
tile.setAttribute("role", "button");
|
|
1654
1725
|
tile.setAttribute("tabindex", "0");
|
|
1655
1726
|
const open = () => {
|
|
1656
|
-
const img = tile.querySelector(".aparte-
|
|
1727
|
+
const img = tile.querySelector(".aparte-thumbnail__image");
|
|
1657
1728
|
if (!img) return;
|
|
1658
1729
|
this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
|
|
1659
1730
|
bubbles: true,
|
|
@@ -1790,6 +1861,27 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1790
1861
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1791
1862
|
// Action Bar
|
|
1792
1863
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1864
|
+
/**
|
|
1865
|
+
* What the copy action copies: the reply, not the reasoning. A `thinking` segment is
|
|
1866
|
+
* the model's scratchpad, and the client already keeps it out of the history it sends
|
|
1867
|
+
* back (`_segmentsToText`) for the same reason — copying it along with the answer
|
|
1868
|
+
* pasted a paragraph of deliberation above every reply, which no assistant on the
|
|
1869
|
+
* market does. Segments with no `content` (a tool call) contribute nothing, so a
|
|
1870
|
+
* turn made only of those copies as `''` — and the action bar reads this to decide
|
|
1871
|
+
* whether to offer the button at all.
|
|
1872
|
+
*/
|
|
1873
|
+
_copyableText() {
|
|
1874
|
+
return (this._content || this._segments.filter((s2) => s2.type !== "thinking").map((s2) => s2.content ?? "").join("\n")).trim();
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* The copy button is for text. `hidden` rather than removed: the bar is rebuilt
|
|
1878
|
+
* rarely and the text changes on every streamed token, so the button is toggled in
|
|
1879
|
+
* place from each path that changes what a copy would paste.
|
|
1880
|
+
*/
|
|
1881
|
+
_syncCopyAction() {
|
|
1882
|
+
const btn = this._actionBarEl?.querySelector(".aparte-action-copy");
|
|
1883
|
+
if (btn) btn.hidden = this._copyableText() === "";
|
|
1884
|
+
}
|
|
1793
1885
|
_updateActionBar() {
|
|
1794
1886
|
if (!this._actionBarEl) return;
|
|
1795
1887
|
if (this._editing) {
|
|
@@ -1821,6 +1913,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1821
1913
|
}
|
|
1822
1914
|
}
|
|
1823
1915
|
this._actionBarEl.innerHTML = buttons.join("");
|
|
1916
|
+
this._syncCopyAction();
|
|
1824
1917
|
this._appendCustomActions(icons);
|
|
1825
1918
|
this._applyTranscriptBusy();
|
|
1826
1919
|
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
@@ -1912,7 +2005,8 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1912
2005
|
const roles = a4.bubble?.roles ?? ["user", "assistant"];
|
|
1913
2006
|
if (!roles.includes(this._role)) continue;
|
|
1914
2007
|
const btn = document.createElement("button");
|
|
1915
|
-
btn.
|
|
2008
|
+
btn.type = "button";
|
|
2009
|
+
btn.className = "aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-custom";
|
|
1916
2010
|
btn.dataset["action"] = `custom:${a4.id}`;
|
|
1917
2011
|
btn.setAttribute("aria-label", a4.label);
|
|
1918
2012
|
btn.setAttribute("title", a4.label);
|
|
@@ -1926,28 +2020,28 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1926
2020
|
switch (action) {
|
|
1927
2021
|
case "copy": {
|
|
1928
2022
|
const l2 = locale.copy ?? "Copy";
|
|
1929
|
-
return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.copy()}</button>`;
|
|
2023
|
+
return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.copy()}</button>`;
|
|
1930
2024
|
}
|
|
1931
2025
|
case "edit": {
|
|
1932
2026
|
const l2 = locale.edit ?? "Edit message";
|
|
1933
|
-
return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.edit()}</button>`;
|
|
2027
|
+
return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.edit()}</button>`;
|
|
1934
2028
|
}
|
|
1935
2029
|
case "retry": {
|
|
1936
2030
|
const l2 = locale.retry ?? "Retry";
|
|
1937
|
-
return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.retry()}</button>`;
|
|
2031
|
+
return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.retry()}</button>`;
|
|
1938
2032
|
}
|
|
1939
2033
|
case "thumbUp": {
|
|
1940
2034
|
const l2 = locale.feedbackPositive ?? "Good response";
|
|
1941
|
-
return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbUp()}</button>`;
|
|
2035
|
+
return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbUp()}</button>`;
|
|
1942
2036
|
}
|
|
1943
2037
|
case "thumbDown": {
|
|
1944
2038
|
const l2 = locale.feedbackNegative ?? "Bad response";
|
|
1945
|
-
return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbDown()}</button>`;
|
|
2039
|
+
return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbDown()}</button>`;
|
|
1946
2040
|
}
|
|
1947
2041
|
case "info": {
|
|
1948
2042
|
if (!this._usage) return "";
|
|
1949
2043
|
const l2 = locale.messageInfo ?? "Details";
|
|
1950
|
-
return `<button class="aparte-btn aparte-btn--icon aparte-action-btn aparte-action-info" data-action="info" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${this._cfg.getIcon("info")}</button>`;
|
|
2044
|
+
return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-info" data-action="info" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${this._cfg.getIcon("info")}</button>`;
|
|
1951
2045
|
}
|
|
1952
2046
|
default:
|
|
1953
2047
|
return "";
|
|
@@ -1959,7 +2053,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1959
2053
|
const locale = this._cfg.getLocale();
|
|
1960
2054
|
const saveLabel = locale.editConfirm ?? "Save";
|
|
1961
2055
|
const cancelLabel = locale.editCancel ?? "Cancel";
|
|
1962
|
-
this._actionBarEl.innerHTML = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--success aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${escapeAttr(saveLabel)}" title="${escapeAttr(saveLabel)}">${this._cfg.getIcon("check")}</button><button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--danger aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${escapeAttr(cancelLabel)}" title="${escapeAttr(cancelLabel)}">${this._cfg.getIcon("close")}</button>`;
|
|
2056
|
+
this._actionBarEl.innerHTML = `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--success aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${escapeAttr(saveLabel)}" title="${escapeAttr(saveLabel)}">${this._cfg.getIcon("check")}</button><button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--danger aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${escapeAttr(cancelLabel)}" title="${escapeAttr(cancelLabel)}">${this._cfg.getIcon("close")}</button>`;
|
|
1963
2057
|
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
1964
2058
|
btn.addEventListener("click", (e) => this._handleActionClick(e));
|
|
1965
2059
|
});
|
|
@@ -1988,7 +2082,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1988
2082
|
}
|
|
1989
2083
|
switch (action) {
|
|
1990
2084
|
case "copy": {
|
|
1991
|
-
const text = this.
|
|
2085
|
+
const text = this._copyableText();
|
|
1992
2086
|
const icons = this._cfg.getIconProvider();
|
|
1993
2087
|
const locale = this._cfg.getLocale();
|
|
1994
2088
|
copyText(text).then(() => {
|
|
@@ -2072,6 +2166,8 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2072
2166
|
*/
|
|
2073
2167
|
_enterEditMode() {
|
|
2074
2168
|
if (this._editing || !this._contentEl) return;
|
|
2169
|
+
const opener = document.activeElement;
|
|
2170
|
+
this._editReturnAction = opener instanceof HTMLElement && this.contains(opener) ? opener.getAttribute("data-action") ?? "edit" : null;
|
|
2075
2171
|
this._editing = true;
|
|
2076
2172
|
this.querySelector(".aparte-message")?.setAttribute("data-editing", "");
|
|
2077
2173
|
const input = document.createElement("aparte-composer-input");
|
|
@@ -2105,6 +2201,16 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2105
2201
|
this.querySelector(".aparte-message")?.removeAttribute("data-editing");
|
|
2106
2202
|
this._editing = false;
|
|
2107
2203
|
this._updateActionBar();
|
|
2204
|
+
const returnTo = this._editReturnAction;
|
|
2205
|
+
this._editReturnAction = null;
|
|
2206
|
+
if (returnTo && this._actionBarEl) {
|
|
2207
|
+
const back = Array.from(this._actionBarEl.querySelectorAll("[data-action]")).find((btn) => btn.getAttribute("data-action") === returnTo);
|
|
2208
|
+
const target = back && !back.disabled ? back : this._actionBarEl.querySelector("button:not([disabled])");
|
|
2209
|
+
if (target) {
|
|
2210
|
+
for (const button of this._rovingButtons()) button.tabIndex = button === target ? 0 : -1;
|
|
2211
|
+
target.focus();
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2108
2214
|
if (save && newContent && newContent !== original) {
|
|
2109
2215
|
const messageId = this.getAttribute("message-id");
|
|
2110
2216
|
if (messageId) {
|
|
@@ -2252,27 +2358,63 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2252
2358
|
<span class="aparte-dot"></span>
|
|
2253
2359
|
</div>
|
|
2254
2360
|
<span class="aparte-status-text"></span>
|
|
2361
|
+
<span class="aparte-sr-only aparte-status-sr"></span>
|
|
2255
2362
|
</div>
|
|
2256
2363
|
</div>
|
|
2257
2364
|
</div>
|
|
2258
2365
|
`;
|
|
2259
2366
|
this.querySelector(".aparte-status-container")?.setAttribute("aria-label", text);
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2367
|
+
this._writeText(this.getAttribute("text"));
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* The one writer for both text nodes, so exactly ONE of them is ever populated.
|
|
2371
|
+
*
|
|
2372
|
+
* The container is a polite live region, and a live region announces its CONTENT.
|
|
2373
|
+
* The dots-only default had none — an `aria-hidden` dot and an empty span — so the
|
|
2374
|
+
* whole state rode on `aria-label`, which names the region rather than reporting it,
|
|
2375
|
+
* and nothing was announced. The screen-reader span carries the fallback word in that
|
|
2376
|
+
* case; when `text` is set the visible span already carries the same string, and
|
|
2377
|
+
* populating both would have the region read it twice.
|
|
2378
|
+
*/
|
|
2379
|
+
_writeText(text) {
|
|
2380
|
+
const line = text || null;
|
|
2381
|
+
const textEl = this.querySelector(".aparte-status-text");
|
|
2382
|
+
if (textEl) textEl.textContent = line ?? "";
|
|
2383
|
+
this._syncLiveWord();
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* The fallback word enters and leaves the live region WITH the element's visibility.
|
|
2387
|
+
*
|
|
2388
|
+
* Writing it at render time put it in the region while the host was still
|
|
2389
|
+
* `display: none` — every wrapper mounts `<aparte-chat-status>` once and flips
|
|
2390
|
+
* `visible` — so by the time the region was exposed its content had not changed, and
|
|
2391
|
+
* on the second and third turn there was not even a reveal to notice: the text was
|
|
2392
|
+
* byte-identical to what was already sitting there. Revealing a region that already
|
|
2393
|
+
* holds its text is the path assistive tech is documented not to announce reliably;
|
|
2394
|
+
* mutating one that is already exposed is the path that works. So the word is written
|
|
2395
|
+
* when `visible` arrives and cleared when it leaves, which makes every turn a real
|
|
2396
|
+
* change.
|
|
2397
|
+
*
|
|
2398
|
+
* With `text` set the visible span is the region's content and the screen-reader span
|
|
2399
|
+
* stays empty — one copy, read once.
|
|
2400
|
+
*/
|
|
2401
|
+
_syncLiveWord() {
|
|
2402
|
+
const srEl = this.querySelector(".aparte-status-sr");
|
|
2403
|
+
if (!srEl) return;
|
|
2404
|
+
const dotsOnly = !this.getAttribute("text");
|
|
2405
|
+
srEl.textContent = dotsOnly && this.hasAttribute("visible") ? "Typing" : "";
|
|
2264
2406
|
}
|
|
2265
2407
|
_updateVisibility(visible) {
|
|
2266
2408
|
const container = this.querySelector(".aparte-status-container");
|
|
2267
2409
|
if (container) {
|
|
2268
2410
|
container.setAttribute("data-visible", String(visible));
|
|
2269
2411
|
}
|
|
2412
|
+
this._syncLiveWord();
|
|
2270
2413
|
}
|
|
2271
2414
|
_updateText(text) {
|
|
2272
|
-
const textEl = this.querySelector(".aparte-status-text");
|
|
2273
2415
|
const container = this.querySelector(".aparte-status-container");
|
|
2274
2416
|
if (!container) return;
|
|
2275
|
-
|
|
2417
|
+
this._writeText(text);
|
|
2276
2418
|
container.setAttribute("aria-label", text || "Typing");
|
|
2277
2419
|
}
|
|
2278
2420
|
}
|
|
@@ -2352,6 +2494,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2352
2494
|
_overlayObserver = null;
|
|
2353
2495
|
/** Last `--aparte-bottom-inset` written, in px — style writes only when it changes. */
|
|
2354
2496
|
_overlayInset = -1;
|
|
2497
|
+
/** Last `--aparte-transcript-inset` written on the host, in px — same write-on-change rule. */
|
|
2498
|
+
_transcriptInset = -1;
|
|
2355
2499
|
_boundResetHandler = null;
|
|
2356
2500
|
/**
|
|
2357
2501
|
* When true, _reRenderActivePath() only dispatches aparte-path-changed without
|
|
@@ -2372,6 +2516,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2372
2516
|
this._render();
|
|
2373
2517
|
this._setupEventListeners();
|
|
2374
2518
|
this._setupObservers();
|
|
2519
|
+
this._updateTranscriptInset();
|
|
2375
2520
|
this._boundResetHandler = () => this.clearAll();
|
|
2376
2521
|
window.addEventListener("aparte-reset", this._boundResetHandler);
|
|
2377
2522
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
@@ -3275,6 +3420,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3275
3420
|
_setupObservers() {
|
|
3276
3421
|
this._resizeObserver = new ResizeObserver(() => {
|
|
3277
3422
|
this._updateOverlayInset();
|
|
3423
|
+
this._updateTranscriptInset();
|
|
3278
3424
|
if (this._isAutoScrollEnabled) {
|
|
3279
3425
|
this._scrollToBottom();
|
|
3280
3426
|
}
|
|
@@ -3355,6 +3501,32 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3355
3501
|
this._overlayInset = inset;
|
|
3356
3502
|
this.style.setProperty("--aparte-bottom-inset", `${inset}px`);
|
|
3357
3503
|
}
|
|
3504
|
+
/**
|
|
3505
|
+
* Publish, on the chat host, how far from the host's inline edge the rows start —
|
|
3506
|
+
* the transcript's padding plus the scrollbar gutter the scroller reserves on both
|
|
3507
|
+
* edges, at whatever step its container query has taken — as
|
|
3508
|
+
* `--aparte-transcript-inset`. The composer pads by it (composer.css), so its box
|
|
3509
|
+
* lands on the row's box at every width. The two used to be independent stacks: the
|
|
3510
|
+
* composer cannot know the gutter and a container query cannot reach it, so they sat
|
|
3511
|
+
* 10px apart at 768 and the gutter's half apart at 1280, and the reading-column demo
|
|
3512
|
+
* showed three left edges 12px apart. Written only when the value changes, for the
|
|
3513
|
+
* same reason `_updateOverlayInset` is. On the HOST, not on this element: the
|
|
3514
|
+
* composer is a sibling, and a custom property only travels down.
|
|
3515
|
+
*/
|
|
3516
|
+
_updateTranscriptInset() {
|
|
3517
|
+
const host = this.parentElement;
|
|
3518
|
+
if (!host) return;
|
|
3519
|
+
const ref = this.querySelector(".aparte-messages-wrapper") ?? this._container ?? this;
|
|
3520
|
+
const pad = parseFloat(getComputedStyle(ref).paddingInlineStart) || 0;
|
|
3521
|
+
const rtl = getComputedStyle(host).direction === "rtl";
|
|
3522
|
+
const refRect = ref.getBoundingClientRect();
|
|
3523
|
+
const hostRect = host.getBoundingClientRect();
|
|
3524
|
+
const raw = rtl ? hostRect.right - refRect.right : refRect.left - hostRect.left;
|
|
3525
|
+
const inset = Math.max(0, Math.round((raw + pad) * 10) / 10);
|
|
3526
|
+
if (inset === this._transcriptInset) return;
|
|
3527
|
+
this._transcriptInset = inset;
|
|
3528
|
+
host.style.setProperty("--aparte-transcript-inset", `${inset}px`);
|
|
3529
|
+
}
|
|
3358
3530
|
/** Is the scroll surface within `_scrollThreshold` of its bottom, right now? */
|
|
3359
3531
|
_isAtBottom() {
|
|
3360
3532
|
if (!this._container) return true;
|
|
@@ -3617,6 +3789,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3617
3789
|
this._overlayObserver = null;
|
|
3618
3790
|
this._overlayRoot = null;
|
|
3619
3791
|
this._overlayInset = -1;
|
|
3792
|
+
this._transcriptInset = -1;
|
|
3620
3793
|
this._resizeObserver = null;
|
|
3621
3794
|
this._mutationObserver = null;
|
|
3622
3795
|
if (this._spacerRafId !== null) {
|
|
@@ -3927,6 +4100,8 @@ class AparteComposer extends HTMLElement {
|
|
|
3927
4100
|
onEvict?.();
|
|
3928
4101
|
}
|
|
3929
4102
|
_teardownPanel() {
|
|
4103
|
+
const active = document.activeElement;
|
|
4104
|
+
const hadFocus = active instanceof Node && this.contains(active);
|
|
3930
4105
|
const existing = this.querySelector("[data-aparte-panel]");
|
|
3931
4106
|
if (existing) existing.remove();
|
|
3932
4107
|
this.removeAttribute("data-panel-active");
|
|
@@ -3938,7 +4113,7 @@ class AparteComposer extends HTMLElement {
|
|
|
3938
4113
|
this._panelOnEvict = null;
|
|
3939
4114
|
this._panelToken = null;
|
|
3940
4115
|
this._emit("panel-change", { active: false, submitEnabled: false, mode: "submit" });
|
|
3941
|
-
this.focus();
|
|
4116
|
+
if (hadFocus) this.focus();
|
|
3942
4117
|
}
|
|
3943
4118
|
/**
|
|
3944
4119
|
* Update the send button's state while a panel is active.
|
|
@@ -4000,7 +4175,14 @@ class AparteComposer extends HTMLElement {
|
|
|
4000
4175
|
const detail = {
|
|
4001
4176
|
content: value,
|
|
4002
4177
|
timestamp: Date.now(),
|
|
4003
|
-
|
|
4178
|
+
/*
|
|
4179
|
+
* `_ownTargetId()`, not the bare attribute — the same resolution `cancel()`
|
|
4180
|
+
* uses, and for the same reason. The wrappers all set `target`, so this is
|
|
4181
|
+
* byte-identical there; in RAW core nothing writes it, so every send carried
|
|
4182
|
+
* `targetId: undefined` and a page with two hand-written chats delivered the
|
|
4183
|
+
* reply to whichever one the host resolved first.
|
|
4184
|
+
*/
|
|
4185
|
+
targetId: this._ownTargetId(),
|
|
4004
4186
|
files: this._attachments.length > 0 ? [...this._attachments] : void 0
|
|
4005
4187
|
};
|
|
4006
4188
|
this.dispatchEvent(new CustomEvent("aparte-send", {
|
|
@@ -4400,6 +4582,7 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4400
4582
|
const root = this._getRoot();
|
|
4401
4583
|
const disabled = !root || root.disabled || root.value.trim() === "";
|
|
4402
4584
|
this.innerHTML = `<button
|
|
4585
|
+
type="button"
|
|
4403
4586
|
class="aparte-btn aparte-btn--primary aparte-btn--solid aparte-btn--icon aparte-cs-button aparte-send-button"
|
|
4404
4587
|
aria-label="${escapeAttr(label)}"
|
|
4405
4588
|
title="${escapeAttr(label)}"
|
|
@@ -4551,6 +4734,7 @@ class AparteComposerCancel extends HTMLElement {
|
|
|
4551
4734
|
const label = resolveConfig(this).t("stopButton") || "Stop";
|
|
4552
4735
|
const icon = this._getStopIcon();
|
|
4553
4736
|
this.innerHTML = `<button
|
|
4737
|
+
type="button"
|
|
4554
4738
|
class="aparte-btn aparte-btn--icon aparte-cc-button"
|
|
4555
4739
|
aria-label="${escapeAttr(label)}"
|
|
4556
4740
|
title="${escapeAttr(label)}"
|
|
@@ -4633,9 +4817,9 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
4633
4817
|
if (file.type.startsWith("image/")) {
|
|
4634
4818
|
const url = URL.createObjectURL(file);
|
|
4635
4819
|
this._objectUrls.push(url);
|
|
4636
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-
|
|
4820
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeAttr(url)}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
|
|
4637
4821
|
}
|
|
4638
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-
|
|
4822
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
|
|
4639
4823
|
}).join("");
|
|
4640
4824
|
this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
|
|
4641
4825
|
btn.addEventListener("click", (e) => {
|
|
@@ -4645,20 +4829,22 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
4645
4829
|
});
|
|
4646
4830
|
});
|
|
4647
4831
|
if (!resolveConfig(this).getHostHandlers().attachmentPreview) return;
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
tile.
|
|
4832
|
+
const images = files.filter((f2) => f2.type.startsWith("image/"));
|
|
4833
|
+
this.querySelectorAll(".aparte-thumb--image").forEach((tile, i2) => {
|
|
4834
|
+
const img = tile.querySelector(".aparte-thumbnail__image");
|
|
4835
|
+
if (!img) return;
|
|
4836
|
+
img.setAttribute("role", "button");
|
|
4837
|
+
img.setAttribute("tabindex", "0");
|
|
4838
|
+
img.setAttribute("aria-label", images[i2]?.name ?? "");
|
|
4651
4839
|
const open = () => {
|
|
4652
|
-
const img = tile.querySelector(".aparte-thumb__img");
|
|
4653
|
-
if (!img) return;
|
|
4654
4840
|
this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
|
|
4655
4841
|
bubbles: true,
|
|
4656
4842
|
composed: true,
|
|
4657
|
-
detail: { url: img.src, name:
|
|
4843
|
+
detail: { url: img.src, name: images[i2]?.name ?? "" }
|
|
4658
4844
|
}));
|
|
4659
4845
|
};
|
|
4660
|
-
|
|
4661
|
-
|
|
4846
|
+
img.addEventListener("click", open);
|
|
4847
|
+
img.addEventListener("keydown", (e) => {
|
|
4662
4848
|
const key = e.key;
|
|
4663
4849
|
if (key !== "Enter" && key !== " ") return;
|
|
4664
4850
|
e.preventDefault();
|
|
@@ -5288,7 +5474,10 @@ class AparteConversationList extends HTMLElement {
|
|
|
5288
5474
|
input.dataset["renameId"] = conv.id;
|
|
5289
5475
|
select.replaceWith(input);
|
|
5290
5476
|
this._renaming = { id: conv.id, input, done: false };
|
|
5291
|
-
input.addEventListener("blur", () =>
|
|
5477
|
+
input.addEventListener("blur", (e) => {
|
|
5478
|
+
const next = e.relatedTarget;
|
|
5479
|
+
this._finishRename(true, !next || this.contains(next));
|
|
5480
|
+
});
|
|
5292
5481
|
input.focus();
|
|
5293
5482
|
input.select();
|
|
5294
5483
|
}
|
|
@@ -5297,23 +5486,28 @@ class AparteConversationList extends HTMLElement {
|
|
|
5297
5486
|
* the blur a removal causes cannot commit a second time. The row is re-rendered
|
|
5298
5487
|
* from the array either way: the list shows data, and the host's re-assignment is
|
|
5299
5488
|
* what makes the new title appear.
|
|
5489
|
+
*
|
|
5490
|
+
* `restoreFocus` puts the keyboard back on the row: every exit destroys the field
|
|
5491
|
+
* the reader was in, so without it the focus lands on `<body>` and the next Tab
|
|
5492
|
+
* restarts at the top of the page. The caller decides — the blur path says no when
|
|
5493
|
+
* the focus is already heading somewhere live outside this list.
|
|
5300
5494
|
*/
|
|
5301
|
-
_finishRename(commit,
|
|
5495
|
+
_finishRename(commit, restoreFocus) {
|
|
5302
5496
|
const renaming = this._renaming;
|
|
5303
5497
|
if (!renaming || renaming.done) return;
|
|
5304
5498
|
renaming.done = true;
|
|
5305
5499
|
const conv = this._conversations.find((c2) => c2.id === renaming.id);
|
|
5306
5500
|
const title = renaming.input.value.trim();
|
|
5307
5501
|
this._render();
|
|
5308
|
-
if (viaKeyboard) {
|
|
5309
|
-
this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
|
|
5310
|
-
}
|
|
5311
5502
|
if (commit && conv && title && title !== conv.title) {
|
|
5312
5503
|
this.dispatchEvent(new CustomEvent(
|
|
5313
5504
|
"aparte-rename-conversation",
|
|
5314
5505
|
{ detail: { id: conv.id, title }, bubbles: true, composed: true }
|
|
5315
5506
|
));
|
|
5316
5507
|
}
|
|
5508
|
+
if (restoreFocus) {
|
|
5509
|
+
this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
|
|
5510
|
+
}
|
|
5317
5511
|
}
|
|
5318
5512
|
// ─── Events ───────────────────────────────────────────────────────────
|
|
5319
5513
|
_onClick = (e) => {
|
|
@@ -5914,9 +6108,6 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5914
6108
|
if (!customElements.get("aparte-scroll-rail")) {
|
|
5915
6109
|
customElements.define("aparte-scroll-rail", AparteScrollRail);
|
|
5916
6110
|
}
|
|
5917
|
-
function presenceOn(value) {
|
|
5918
|
-
return value === "" ? true : Boolean(value);
|
|
5919
|
-
}
|
|
5920
6111
|
const DRAWER_QUERY = "(max-width: 48rem)";
|
|
5921
6112
|
class AparteSidebar extends HTMLElement {
|
|
5922
6113
|
static get observedAttributes() {
|
|
@@ -5987,6 +6178,8 @@ class AparteSidebar extends HTMLElement {
|
|
|
5987
6178
|
this._watchBreakpoint();
|
|
5988
6179
|
this._syncHidden();
|
|
5989
6180
|
this._lastCollapsed = this.collapsed;
|
|
6181
|
+
if (!this.id) this.id = `aparte-sidebar-${++sidebarIdSeq}`;
|
|
6182
|
+
this._syncToggles();
|
|
5990
6183
|
this._ready = true;
|
|
5991
6184
|
}
|
|
5992
6185
|
/**
|
|
@@ -6030,6 +6223,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6030
6223
|
if (!this._auto && !this.drawer) this._closedByHost = collapsed;
|
|
6031
6224
|
this._syncScrim();
|
|
6032
6225
|
this._syncHidden();
|
|
6226
|
+
this._syncToggles();
|
|
6033
6227
|
if (collapsed && this.drawer && this._opener?.isConnected) {
|
|
6034
6228
|
this._opener.focus();
|
|
6035
6229
|
this._opener = null;
|
|
@@ -6074,6 +6268,26 @@ class AparteSidebar extends HTMLElement {
|
|
|
6074
6268
|
}
|
|
6075
6269
|
this._syncScrim();
|
|
6076
6270
|
this._syncHidden();
|
|
6271
|
+
this._syncToggles();
|
|
6272
|
+
}
|
|
6273
|
+
/**
|
|
6274
|
+
* Every `[data-aparte-sidebar-toggle]` that drives THIS sidebar says what it does:
|
|
6275
|
+
* `aria-expanded` follows the state, `aria-controls` names the element. Synced from
|
|
6276
|
+
* the three places the state settles (connect, the attribute, the breakpoint), never
|
|
6277
|
+
* from `open()` alone — a close on Escape would otherwise leave an "expanded" that
|
|
6278
|
+
* lies. The row's own `⋯` button already did this; the sidebar's toggle did not.
|
|
6279
|
+
*/
|
|
6280
|
+
_syncToggles() {
|
|
6281
|
+
for (const control of document.querySelectorAll("[data-aparte-sidebar-toggle]")) {
|
|
6282
|
+
if (!this._drives(control)) continue;
|
|
6283
|
+
control.setAttribute("aria-expanded", String(!this.collapsed));
|
|
6284
|
+
control.setAttribute("aria-controls", this.id);
|
|
6285
|
+
}
|
|
6286
|
+
}
|
|
6287
|
+
/** Whether a toggle control targets this sidebar: named by id, else the nearest one. */
|
|
6288
|
+
_drives(control) {
|
|
6289
|
+
const named = control.getAttribute("data-aparte-sidebar-toggle");
|
|
6290
|
+
return named ? named === this.id : nearestSidebar(control) === this;
|
|
6077
6291
|
}
|
|
6078
6292
|
/**
|
|
6079
6293
|
* A collapsed sidebar is invisible — folded to nothing in the flow, slid off the
|
|
@@ -6110,7 +6324,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6110
6324
|
* nothing focusable in it keeps the focus where it was, which is the honest outcome.
|
|
6111
6325
|
*/
|
|
6112
6326
|
_focusFirst() {
|
|
6113
|
-
this
|
|
6327
|
+
visibleFocusable(this)[0]?.focus();
|
|
6114
6328
|
}
|
|
6115
6329
|
_syncScrim() {
|
|
6116
6330
|
const wanted = this.drawer && !this.collapsed;
|
|
@@ -6126,21 +6340,35 @@ class AparteSidebar extends HTMLElement {
|
|
|
6126
6340
|
}
|
|
6127
6341
|
}
|
|
6128
6342
|
_onKeydown = (e) => {
|
|
6129
|
-
if (
|
|
6343
|
+
if (!this.drawer || this.collapsed) return;
|
|
6344
|
+
if (e.key === "Escape") {
|
|
6130
6345
|
e.preventDefault();
|
|
6131
6346
|
this.close();
|
|
6347
|
+
return;
|
|
6348
|
+
}
|
|
6349
|
+
if (e.key === "Tab") {
|
|
6350
|
+
const focusable = visibleFocusable(this);
|
|
6351
|
+
if (focusable.length === 0) return;
|
|
6352
|
+
const first = focusable[0];
|
|
6353
|
+
const last = focusable[focusable.length - 1];
|
|
6354
|
+
const active = document.activeElement;
|
|
6355
|
+
if (!this.contains(active)) {
|
|
6356
|
+
e.preventDefault();
|
|
6357
|
+
first.focus();
|
|
6358
|
+
} else if (e.shiftKey && active === first) {
|
|
6359
|
+
e.preventDefault();
|
|
6360
|
+
last.focus();
|
|
6361
|
+
} else if (!e.shiftKey && active === last) {
|
|
6362
|
+
e.preventDefault();
|
|
6363
|
+
first.focus();
|
|
6364
|
+
}
|
|
6132
6365
|
}
|
|
6133
6366
|
};
|
|
6134
6367
|
/** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
|
|
6135
6368
|
_onDocumentClick = (e) => {
|
|
6136
6369
|
const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
|
|
6137
6370
|
if (!control) return;
|
|
6138
|
-
|
|
6139
|
-
if (named) {
|
|
6140
|
-
if (named !== this.id) return;
|
|
6141
|
-
} else if (nearestSidebar(control) !== this) {
|
|
6142
|
-
return;
|
|
6143
|
-
}
|
|
6371
|
+
if (!this._drives(control)) return;
|
|
6144
6372
|
this.toggle(control);
|
|
6145
6373
|
};
|
|
6146
6374
|
// ─── Search ───────────────────────────────────────────────────────────
|
|
@@ -6174,6 +6402,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6174
6402
|
this.dataset["ownLabel"] = "locale";
|
|
6175
6403
|
}
|
|
6176
6404
|
}
|
|
6405
|
+
let sidebarIdSeq = 0;
|
|
6177
6406
|
const FOCUSABLE = [
|
|
6178
6407
|
"a[href]",
|
|
6179
6408
|
"button:not([disabled])",
|
|
@@ -6182,6 +6411,9 @@ const FOCUSABLE = [
|
|
|
6182
6411
|
"textarea:not([disabled])",
|
|
6183
6412
|
'[tabindex]:not([tabindex="-1"])'
|
|
6184
6413
|
].join(",");
|
|
6414
|
+
function visibleFocusable(root) {
|
|
6415
|
+
return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
|
|
6416
|
+
}
|
|
6185
6417
|
function nearestSidebar(control) {
|
|
6186
6418
|
return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
6187
6419
|
}
|