@aparte/core 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/aparte-client.d.ts +53 -51
- package/dist/client/aparte-client.d.ts.map +1 -1
- package/dist/client/lifecycle-events.d.ts +0 -12
- package/dist/client/lifecycle-events.d.ts.map +1 -1
- package/dist/client/stream-adapter.d.ts +2 -12
- package/dist/client/stream-adapter.d.ts.map +1 -1
- package/dist/components/bubble/aparte-chat-bubble.d.ts +45 -1
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-attachments.d.ts +0 -1
- package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-send.d.ts +11 -11
- package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer.d.ts +20 -11
- package/dist/components/composer/aparte-composer.d.ts.map +1 -1
- package/dist/components/context/aparte-context.d.ts +31 -8
- package/dist/components/context/aparte-context.d.ts.map +1 -1
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +93 -36
- package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
- package/dist/components/conversation-list/index.d.ts +1 -1
- package/dist/components/conversation-list/index.d.ts.map +1 -1
- package/dist/components/elicitation/aparte-elicitation.d.ts +19 -1
- package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
- package/dist/components/index.d.ts +7 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +105 -0
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -0
- package/dist/components/scroll-rail/index.d.ts +3 -0
- package/dist/components/scroll-rail/index.d.ts.map +1 -0
- package/dist/components/sidebar/aparte-sidebar.d.ts +186 -0
- package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -0
- package/dist/components/sidebar/index.d.ts +3 -0
- package/dist/components/sidebar/index.d.ts.map +1 -0
- package/dist/components/split/aparte-split.d.ts +354 -0
- package/dist/components/split/aparte-split.d.ts.map +1 -0
- package/dist/components/split/geometry.d.ts +65 -0
- package/dist/components/split/geometry.d.ts.map +1 -0
- package/dist/components/split/index.d.ts +3 -0
- package/dist/components/split/index.d.ts.map +1 -0
- package/dist/components/suggestions/aparte-suggestions.d.ts +7 -1
- package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts +52 -2
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
- package/dist/config/aparte-config.d.ts +95 -33
- package/dist/config/aparte-config.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/index.d.ts +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/locale.d.ts +140 -27
- package/dist/config/locale.d.ts.map +1 -1
- package/dist/config/sanitize.d.ts +9 -0
- package/dist/config/sanitize.d.ts.map +1 -1
- package/dist/conversations/conversation-controller.d.ts +13 -1
- package/dist/conversations/conversation-controller.d.ts.map +1 -1
- package/dist/conversations/conversation-manager.d.ts +9 -0
- package/dist/conversations/conversation-manager.d.ts.map +1 -1
- package/dist/custom-elements.json +9219 -6730
- package/dist/{is-awaiting-reply-BZ_iJ3s3.js → dialog-triggers-CVCxqKg3.js} +718 -1182
- package/dist/dialog-triggers-CVCxqKg3.js.map +1 -0
- package/dist/elicitation/approval-panel.d.ts +1 -1
- package/dist/elicitation/approval-panel.d.ts.map +1 -1
- package/dist/elicitation/panel.d.ts +24 -16
- package/dist/elicitation/panel.d.ts.map +1 -1
- package/dist/elicitation/types.d.ts +31 -0
- package/dist/elicitation/types.d.ts.map +1 -1
- package/dist/generated/element-attributes.d.ts +47 -1
- package/dist/generated/element-attributes.d.ts.map +1 -1
- package/dist/{glyphs-B3BZ2TIb.js → glyphs-DQTeOj4w.js} +22 -13
- package/dist/{glyphs-B3BZ2TIb.js.map → glyphs-DQTeOj4w.js.map} +1 -1
- package/dist/host/aparte-chat-host.d.ts +10 -2
- package/dist/host/aparte-chat-host.d.ts.map +1 -1
- package/dist/icons/extended.d.ts +4 -2
- package/dist/icons/extended.d.ts.map +1 -1
- package/dist/icons/glyphs.d.ts +9 -0
- package/dist/icons/glyphs.d.ts.map +1 -1
- package/dist/icons.js +16 -15
- package/dist/icons.js.map +1 -1
- package/dist/index.css +1217 -396
- package/dist/index.d.ts +26 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2097 -295
- package/dist/index.js.map +1 -1
- package/dist/index.node.d.ts +21 -5
- package/dist/index.node.d.ts.map +1 -1
- package/dist/index.node.js +39 -34
- package/dist/index.node.js.map +1 -1
- package/dist/interop/dialog-triggers.d.ts +27 -0
- package/dist/interop/dialog-triggers.d.ts.map +1 -0
- package/dist/interop/element-props.d.ts +16 -8
- package/dist/interop/element-props.d.ts.map +1 -1
- package/dist/parsers/aparte-stream-parser.d.ts +52 -22
- package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
- package/dist/parsers/index.d.ts +1 -1
- package/dist/parsers/index.d.ts.map +1 -1
- package/dist/primitives/select/aparte-select.d.ts.map +1 -1
- package/dist/renderers/segment-renderers.d.ts.map +1 -1
- package/dist/renderers/segments/code.d.ts.map +1 -1
- package/dist/renderers/segments/tool-call.d.ts.map +1 -1
- package/dist/runtime/message-repository.d.ts.map +1 -1
- package/dist/transport/backend-handler.d.ts.map +1 -1
- package/dist/transport/backend-transport.d.ts +6 -1
- package/dist/transport/backend-transport.d.ts.map +1 -1
- package/dist/types/chat.d.ts +8 -14
- package/dist/types/chat.d.ts.map +1 -1
- package/dist/types/element-map.d.ts +54 -4
- package/dist/types/element-map.d.ts.map +1 -1
- package/dist/types/event-map.d.ts +16 -10
- package/dist/types/event-map.d.ts.map +1 -1
- package/dist/types/events.d.ts +59 -117
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/imperative-api.d.ts +3 -1
- package/dist/types/imperative-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +5 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/models.d.ts +11 -31
- package/dist/types/models.d.ts.map +1 -1
- package/dist/types/segments.d.ts +11 -47
- package/dist/types/segments.d.ts.map +1 -1
- package/dist/types/stream-blocks.d.ts +57 -0
- package/dist/types/stream-blocks.d.ts.map +1 -0
- package/dist/types/tools.d.ts +57 -0
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/utils/copy-text.d.ts +24 -0
- package/dist/utils/copy-text.d.ts.map +1 -0
- package/dist/utils/css-escape.d.ts +5 -0
- package/dist/utils/css-escape.d.ts.map +1 -1
- package/dist/utils/tool-input.d.ts +20 -0
- package/dist/utils/tool-input.d.ts.map +1 -0
- package/package.json +10 -3
- package/dist/is-awaiting-reply-BZ_iJ3s3.js.map +0 -1
- package/dist/renderers/segments/artifact/binary-file.d.ts +0 -45
- package/dist/renderers/segments/artifact/binary-file.d.ts.map +0 -1
- package/dist/renderers/segments/artifact/card.d.ts +0 -3
- package/dist/renderers/segments/artifact/card.d.ts.map +0 -1
- package/dist/renderers/segments/artifact/preview-document.d.ts +0 -27
- package/dist/renderers/segments/artifact/preview-document.d.ts.map +0 -1
- package/dist/renderers/segments/artifact/shared.d.ts +0 -29
- package/dist/renderers/segments/artifact/shared.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { A, a, b, c, d,
|
|
3
|
-
import { deriveArtifactKind } from "@aparte/engine";
|
|
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-CVCxqKg3.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-CVCxqKg3.js";
|
|
4
3
|
class AparteOption extends HTMLElement {
|
|
5
4
|
static get observedAttributes() {
|
|
6
5
|
return ["value", "disabled", "selected", "data-status"];
|
|
@@ -136,8 +135,8 @@ class AparteOptgroup extends HTMLElement {
|
|
|
136
135
|
chevron.innerHTML = resolveConfig(this).getIcon("expand");
|
|
137
136
|
header.appendChild(chevron);
|
|
138
137
|
header.style.cursor = "pointer";
|
|
139
|
-
header.addEventListener("click", (
|
|
140
|
-
|
|
138
|
+
header.addEventListener("click", (e) => {
|
|
139
|
+
e.stopPropagation();
|
|
141
140
|
this._toggleCollapse();
|
|
142
141
|
});
|
|
143
142
|
}
|
|
@@ -278,6 +277,7 @@ class AparteSelect extends HTMLElement {
|
|
|
278
277
|
return;
|
|
279
278
|
}
|
|
280
279
|
const slottedChildren = Array.from(this.children);
|
|
280
|
+
const cfg = resolveConfig(this);
|
|
281
281
|
const trigger = document.createElement("div");
|
|
282
282
|
trigger.className = "aparte-select-trigger";
|
|
283
283
|
trigger.setAttribute("tabindex", "0");
|
|
@@ -296,7 +296,7 @@ class AparteSelect extends HTMLElement {
|
|
|
296
296
|
labelSpan.append(labelText, sizer);
|
|
297
297
|
const chevronSpan = document.createElement("span");
|
|
298
298
|
chevronSpan.className = "aparte-select-chevron";
|
|
299
|
-
chevronSpan.innerHTML =
|
|
299
|
+
chevronSpan.innerHTML = cfg.getIcon("expand");
|
|
300
300
|
trigger.append(labelSpan, chevronSpan);
|
|
301
301
|
const dropdown = document.createElement("div");
|
|
302
302
|
dropdown.className = "aparte-select-dropdown";
|
|
@@ -305,8 +305,8 @@ class AparteSelect extends HTMLElement {
|
|
|
305
305
|
const searchInput = document.createElement("input");
|
|
306
306
|
searchInput.type = "text";
|
|
307
307
|
searchInput.className = "aparte-select-search";
|
|
308
|
-
searchInput.placeholder = "Search
|
|
309
|
-
searchInput.setAttribute("aria-label", "Search options");
|
|
308
|
+
searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
|
|
309
|
+
searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
|
|
310
310
|
dropdown.appendChild(searchInput);
|
|
311
311
|
}
|
|
312
312
|
const optionsContainer = document.createElement("div");
|
|
@@ -382,68 +382,68 @@ class AparteSelect extends HTMLElement {
|
|
|
382
382
|
}
|
|
383
383
|
_setupEventListeners() {
|
|
384
384
|
this._trigger?.addEventListener("click", () => this._toggle());
|
|
385
|
-
this._trigger?.addEventListener("keydown", (
|
|
386
|
-
if (
|
|
385
|
+
this._trigger?.addEventListener("keydown", (e) => {
|
|
386
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
387
387
|
if (this._isOpen) return;
|
|
388
|
-
|
|
388
|
+
e.preventDefault();
|
|
389
389
|
this._toggle();
|
|
390
390
|
}
|
|
391
|
-
if (
|
|
392
|
-
|
|
391
|
+
if (e.key === "ArrowDown" && !this._isOpen) {
|
|
392
|
+
e.preventDefault();
|
|
393
393
|
this._openDropdown();
|
|
394
394
|
}
|
|
395
395
|
});
|
|
396
396
|
this.removeEventListener("click", this._boundHandleOptionClick);
|
|
397
397
|
this.addEventListener("click", this._boundHandleOptionClick);
|
|
398
|
-
this._searchInput?.addEventListener("input", (
|
|
399
|
-
const query =
|
|
398
|
+
this._searchInput?.addEventListener("input", (e) => {
|
|
399
|
+
const query = e.target.value.toLowerCase();
|
|
400
400
|
this._filterOptions(query);
|
|
401
401
|
});
|
|
402
402
|
document.addEventListener("click", this._boundHandleDocumentClick);
|
|
403
403
|
document.addEventListener("keydown", this._boundHandleKeydown);
|
|
404
404
|
}
|
|
405
|
-
_handleOptionClick(
|
|
406
|
-
const option =
|
|
405
|
+
_handleOptionClick(e) {
|
|
406
|
+
const option = e.target.closest("aparte-option");
|
|
407
407
|
if (option && !option.hasAttribute("disabled")) {
|
|
408
408
|
this._selectOption(option);
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
|
-
_handleDocumentClick(
|
|
412
|
-
if (!this.contains(
|
|
411
|
+
_handleDocumentClick(e) {
|
|
412
|
+
if (!this.contains(e.target)) {
|
|
413
413
|
this._closeDropdown();
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
|
-
_handleKeydown(
|
|
416
|
+
_handleKeydown(e) {
|
|
417
417
|
if (!this._isOpen) return;
|
|
418
418
|
const inSearch = document.activeElement === this._searchInput;
|
|
419
|
-
switch (
|
|
419
|
+
switch (e.key) {
|
|
420
420
|
case "Escape":
|
|
421
|
-
|
|
421
|
+
e.preventDefault();
|
|
422
422
|
this._closeDropdown();
|
|
423
423
|
this._trigger?.focus();
|
|
424
424
|
break;
|
|
425
425
|
case "ArrowDown":
|
|
426
|
-
|
|
426
|
+
e.preventDefault();
|
|
427
427
|
this._moveActive(1);
|
|
428
428
|
break;
|
|
429
429
|
case "ArrowUp":
|
|
430
|
-
|
|
430
|
+
e.preventDefault();
|
|
431
431
|
this._moveActive(-1);
|
|
432
432
|
break;
|
|
433
433
|
case "Home":
|
|
434
434
|
if (inSearch) break;
|
|
435
|
-
|
|
435
|
+
e.preventDefault();
|
|
436
436
|
this._setActive(0);
|
|
437
437
|
break;
|
|
438
438
|
case "End":
|
|
439
439
|
if (inSearch) break;
|
|
440
|
-
|
|
440
|
+
e.preventDefault();
|
|
441
441
|
this._setActive(this._visibleOptions().length - 1);
|
|
442
442
|
break;
|
|
443
443
|
case "Enter": {
|
|
444
444
|
const active = this._visibleOptions()[this._activeIndex];
|
|
445
445
|
if (active) {
|
|
446
|
-
|
|
446
|
+
e.preventDefault();
|
|
447
447
|
this._selectOption(active);
|
|
448
448
|
}
|
|
449
449
|
break;
|
|
@@ -695,8 +695,8 @@ class AparteElicitation extends HTMLElement {
|
|
|
695
695
|
* cannot identify accepts everything rather than becoming deaf. Only a
|
|
696
696
|
* MISMATCH is ignored.
|
|
697
697
|
*/
|
|
698
|
-
_onTurnEnd = (
|
|
699
|
-
const evtTargetId =
|
|
698
|
+
_onTurnEnd = (e) => {
|
|
699
|
+
const evtTargetId = e.detail?.targetId;
|
|
700
700
|
const own = this._pendingTargetId();
|
|
701
701
|
if (evtTargetId && own && evtTargetId !== own) return;
|
|
702
702
|
this._cancelPending();
|
|
@@ -783,7 +783,7 @@ class AparteElicitation extends HTMLElement {
|
|
|
783
783
|
const approvalMode = () => panel2.isComplete() ? "submit" : "none";
|
|
784
784
|
const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], () => {
|
|
785
785
|
composer.setPanelSubmitEnabled(panel2.isComplete(), approvalMode());
|
|
786
|
-
}));
|
|
786
|
+
}, request.details));
|
|
787
787
|
panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
|
|
788
788
|
this._pending = { abort: () => fail(), composer, relabel: () => panel2.relabel() };
|
|
789
789
|
slot.token = composer.showPanel(panel2.el, {
|
|
@@ -829,11 +829,6 @@ class AparteElicitation extends HTMLElement {
|
|
|
829
829
|
*/
|
|
830
830
|
onEvict: () => fail(),
|
|
831
831
|
onSubmit: () => {
|
|
832
|
-
if (panel.mode() === "advance") {
|
|
833
|
-
panel.proceed();
|
|
834
|
-
composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
832
|
if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
|
|
838
833
|
}
|
|
839
834
|
});
|
|
@@ -1034,11 +1029,17 @@ function resolveSegmentRenderer(type, config) {
|
|
|
1034
1029
|
installDefaultRenderersOnce(config);
|
|
1035
1030
|
return getSegmentRenderer(type, config);
|
|
1036
1031
|
}
|
|
1037
|
-
function segmentRenderResultToElement(result) {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1032
|
+
function segmentRenderResultToElement(result, segment) {
|
|
1033
|
+
let el2;
|
|
1034
|
+
if (result instanceof HTMLElement) {
|
|
1035
|
+
el2 = result;
|
|
1036
|
+
} else {
|
|
1037
|
+
const wrapper = document.createElement("div");
|
|
1038
|
+
wrapper.innerHTML = result;
|
|
1039
|
+
el2 = wrapper.firstElementChild;
|
|
1040
|
+
}
|
|
1041
|
+
if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
|
|
1042
|
+
return el2;
|
|
1042
1043
|
}
|
|
1043
1044
|
class AparteChatBubble extends HTMLElement {
|
|
1044
1045
|
_contentEl = null;
|
|
@@ -1072,8 +1073,8 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1072
1073
|
// Rebuild the action bar when the global config changes (e.g. a live skin
|
|
1073
1074
|
// switch calling setBubbleActions / setIconProvider) so already-rendered
|
|
1074
1075
|
// bubbles pick up the new per-role actions + icons without being re-created.
|
|
1075
|
-
_onConfigChange = (
|
|
1076
|
-
const detail =
|
|
1076
|
+
_onConfigChange = (e) => {
|
|
1077
|
+
const detail = e.detail;
|
|
1077
1078
|
if (detail?.config && detail.config !== this._cfg) return;
|
|
1078
1079
|
this._updateActionBar();
|
|
1079
1080
|
this._updateName();
|
|
@@ -1140,10 +1141,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1140
1141
|
this._updateTimestamp(this.getAttribute("timestamp"));
|
|
1141
1142
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
1142
1143
|
this.addEventListener("click", this._onBranchPickerClick);
|
|
1144
|
+
this.addEventListener("click", this._onLinkClick);
|
|
1143
1145
|
}
|
|
1144
1146
|
disconnectedCallback() {
|
|
1145
1147
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
1146
1148
|
this.removeEventListener("click", this._onBranchPickerClick);
|
|
1149
|
+
this.removeEventListener("click", this._onLinkClick);
|
|
1147
1150
|
if (this._avatarCleanup) {
|
|
1148
1151
|
try {
|
|
1149
1152
|
this._avatarCleanup();
|
|
@@ -1307,7 +1310,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1307
1310
|
this._updateContent();
|
|
1308
1311
|
}
|
|
1309
1312
|
if ("segments" in updates) {
|
|
1310
|
-
this._segments = updates.segments;
|
|
1313
|
+
this._segments = [...updates.segments];
|
|
1311
1314
|
this._renderSegments();
|
|
1312
1315
|
}
|
|
1313
1316
|
if ("timestamp" in updates) {
|
|
@@ -1333,7 +1336,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1333
1336
|
}
|
|
1334
1337
|
const renderer = resolveSegmentRenderer(segment.type, this._cfg);
|
|
1335
1338
|
if (renderer) {
|
|
1336
|
-
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
|
|
1339
|
+
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
|
|
1337
1340
|
if (el2) {
|
|
1338
1341
|
this._segmentsEl.appendChild(el2);
|
|
1339
1342
|
runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
|
|
@@ -1355,7 +1358,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1355
1358
|
if (renderer.update) {
|
|
1356
1359
|
runWithConfig(this._cfg, () => renderer.update(el2, segment));
|
|
1357
1360
|
} else {
|
|
1358
|
-
const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
|
|
1361
|
+
const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
|
|
1359
1362
|
if (newEl) {
|
|
1360
1363
|
el2.replaceWith(newEl);
|
|
1361
1364
|
runWithConfig(this._cfg, () => renderer.setup?.(newEl, segment));
|
|
@@ -1438,6 +1441,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1438
1441
|
this._segmentsEl = this.querySelector(".aparte-segments");
|
|
1439
1442
|
this._attachmentsEl = this.querySelector(".aparte-attachments");
|
|
1440
1443
|
this._actionBarEl = this.querySelector(".aparte-action-bar");
|
|
1444
|
+
this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
|
|
1441
1445
|
this._branchPickerEl = this.querySelector(".aparte-branch-picker");
|
|
1442
1446
|
this._footerEl = this.querySelector(".aparte-footer");
|
|
1443
1447
|
this._transcriptBusy = !!this.closest("aparte-chat-viewport")?.hasAttribute("data-busy");
|
|
@@ -1572,7 +1576,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1572
1576
|
for (const segment of this._segments) {
|
|
1573
1577
|
const renderer = resolveSegmentRenderer(segment.type, this._cfg);
|
|
1574
1578
|
if (renderer) {
|
|
1575
|
-
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
|
|
1579
|
+
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
|
|
1576
1580
|
if (el2) {
|
|
1577
1581
|
this._segmentsEl.appendChild(el2);
|
|
1578
1582
|
runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
|
|
@@ -1631,18 +1635,18 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1631
1635
|
const customAttachment = this._cfg.getAttachmentRenderer?.();
|
|
1632
1636
|
if (customAttachment) {
|
|
1633
1637
|
this._attachmentsEl.replaceChildren();
|
|
1634
|
-
for (const
|
|
1635
|
-
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(
|
|
1638
|
+
for (const a4 of this._attachments) {
|
|
1639
|
+
const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a4)));
|
|
1636
1640
|
if (el2) this._attachmentsEl.appendChild(el2);
|
|
1637
1641
|
}
|
|
1638
1642
|
return;
|
|
1639
1643
|
}
|
|
1640
|
-
this._attachmentsEl.innerHTML = this._attachments.map((
|
|
1641
|
-
const name = escapeHtml(
|
|
1642
|
-
if (
|
|
1643
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(
|
|
1644
|
+
this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
|
|
1645
|
+
const name = escapeHtml(a4.name);
|
|
1646
|
+
if (a4.type.startsWith("image/")) {
|
|
1647
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1644
1648
|
}
|
|
1645
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(
|
|
1649
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1646
1650
|
}).join("");
|
|
1647
1651
|
if (!this._cfg.getHostHandlers().attachmentPreview) return;
|
|
1648
1652
|
this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
|
|
@@ -1658,10 +1662,10 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1658
1662
|
}));
|
|
1659
1663
|
};
|
|
1660
1664
|
tile.addEventListener("click", open);
|
|
1661
|
-
tile.addEventListener("keydown", (
|
|
1662
|
-
const key =
|
|
1665
|
+
tile.addEventListener("keydown", (e) => {
|
|
1666
|
+
const key = e.key;
|
|
1663
1667
|
if (key !== "Enter" && key !== " ") return;
|
|
1664
|
-
|
|
1668
|
+
e.preventDefault();
|
|
1665
1669
|
open();
|
|
1666
1670
|
});
|
|
1667
1671
|
});
|
|
@@ -1707,6 +1711,33 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1707
1711
|
detail
|
|
1708
1712
|
}));
|
|
1709
1713
|
};
|
|
1714
|
+
/**
|
|
1715
|
+
* A link the MODEL wrote is about to be followed. Announced as a cancelable event
|
|
1716
|
+
* before the browser acts, so a host can route it — an external browser, a
|
|
1717
|
+
* confirmation, an embedded view — without intercepting the DOM (issue #38). With
|
|
1718
|
+
* no listener the browser follows it, which the sanitizer has already made open in
|
|
1719
|
+
* a new tab for an external URL; `preventDefault()` on the event cancels the click.
|
|
1720
|
+
*
|
|
1721
|
+
* Only anchors inside the message body: the action bar and the branch picker are
|
|
1722
|
+
* buttons, and an attachment tile is its own event.
|
|
1723
|
+
*/
|
|
1724
|
+
_onLinkClick = (event) => {
|
|
1725
|
+
const target = event.target;
|
|
1726
|
+
const anchor = target?.closest?.("a[href]");
|
|
1727
|
+
if (!anchor || !this.contains(anchor)) return;
|
|
1728
|
+
const detail = {
|
|
1729
|
+
href: anchor.getAttribute("href") ?? "",
|
|
1730
|
+
anchor,
|
|
1731
|
+
messageId: this.getAttribute("message-id")
|
|
1732
|
+
};
|
|
1733
|
+
const announced = this.dispatchEvent(new CustomEvent("aparte-link-click", {
|
|
1734
|
+
bubbles: true,
|
|
1735
|
+
composed: true,
|
|
1736
|
+
cancelable: true,
|
|
1737
|
+
detail
|
|
1738
|
+
}));
|
|
1739
|
+
if (!announced) event.preventDefault();
|
|
1740
|
+
};
|
|
1710
1741
|
/**
|
|
1711
1742
|
* Whether a reply is streaming somewhere in this bubble's transcript. Set by the
|
|
1712
1743
|
* viewport (`data-busy` on it, pushed here), read once on connect for a bubble
|
|
@@ -1771,14 +1802,14 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1771
1802
|
const buttons = [];
|
|
1772
1803
|
if (this._role === "user") {
|
|
1773
1804
|
if (config.user) {
|
|
1774
|
-
for (const
|
|
1805
|
+
for (const a4 of config.user) buttons.push(this._actionButtonHtml(a4, icons, locale));
|
|
1775
1806
|
} else {
|
|
1776
1807
|
if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
|
|
1777
1808
|
if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
|
|
1778
1809
|
}
|
|
1779
1810
|
} else if (this._role === "assistant") {
|
|
1780
1811
|
if (config.assistant) {
|
|
1781
|
-
for (const
|
|
1812
|
+
for (const a4 of config.assistant) buttons.push(this._actionButtonHtml(a4, icons, locale));
|
|
1782
1813
|
} else {
|
|
1783
1814
|
if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
|
|
1784
1815
|
if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
|
|
@@ -1793,10 +1824,71 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1793
1824
|
this._appendCustomActions(icons);
|
|
1794
1825
|
this._applyTranscriptBusy();
|
|
1795
1826
|
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
1796
|
-
btn.addEventListener("click", (
|
|
1827
|
+
btn.addEventListener("click", (e) => this._handleActionClick(e));
|
|
1797
1828
|
});
|
|
1798
1829
|
this._syncFooterVisibility();
|
|
1799
1830
|
}
|
|
1831
|
+
/**
|
|
1832
|
+
* `role="toolbar"` promises a keyboard model. This is it.
|
|
1833
|
+
*
|
|
1834
|
+
* The bar has announced itself as a toolbar since it existed, and a toolbar is ONE
|
|
1835
|
+
* tab stop whose members are reached with the arrow keys — that is the whole of what
|
|
1836
|
+
* the role means to a screen-reader user. What shipped was five independent tab
|
|
1837
|
+
* stops per message, so the role described a behaviour that did not exist: a reader
|
|
1838
|
+
* told to press Right heard nothing move, and tabbing through a long transcript
|
|
1839
|
+
* walked every button of every message.
|
|
1840
|
+
*
|
|
1841
|
+
* The roving index is re-applied from `_syncFooterVisibility`, which is the single
|
|
1842
|
+
* funnel all three build paths already run through (the flag-driven bar, the edit
|
|
1843
|
+
* bar, and the branch picker's own visibility pass). Re-deriving it there rather
|
|
1844
|
+
* than in each builder is what keeps the invariant true after a `setBubbleActions`
|
|
1845
|
+
* rebuild — the drift this pattern usually dies of.
|
|
1846
|
+
*
|
|
1847
|
+
* DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
|
|
1848
|
+
* retry and edit are disabled, and a toolbar whose arrows stop on a dead control
|
|
1849
|
+
* reads as broken. If every button is disabled the bar keeps one tab stop anyway, so
|
|
1850
|
+
* focus sitting there when the turn ends is not thrown to the top of the page.
|
|
1851
|
+
*/
|
|
1852
|
+
_rovingButtons() {
|
|
1853
|
+
if (!this._actionBarEl) return [];
|
|
1854
|
+
return [...this._actionBarEl.querySelectorAll("button")];
|
|
1855
|
+
}
|
|
1856
|
+
_setRovingIndex() {
|
|
1857
|
+
const buttons = this._rovingButtons();
|
|
1858
|
+
if (!buttons.length) return;
|
|
1859
|
+
const usable = buttons.filter((b2) => !b2.disabled);
|
|
1860
|
+
const current = buttons.find((b2) => b2.tabIndex === 0 && !b2.disabled);
|
|
1861
|
+
const stop = current ?? usable[0] ?? buttons[0];
|
|
1862
|
+
for (const button of buttons) button.tabIndex = button === stop ? 0 : -1;
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Left/Right walk the bar, Home/End jump to its ends, and both wrap.
|
|
1866
|
+
*
|
|
1867
|
+
* Arrow keys follow the READING direction, per the ARIA practices: in an RTL
|
|
1868
|
+
* transcript Left is "next". `dir` is resolved from the nearest ancestor that sets
|
|
1869
|
+
* it — the viewport writes it from `locale.direction` — because a bubble has none of
|
|
1870
|
+
* its own and `getComputedStyle` is not something jsdom can answer.
|
|
1871
|
+
*/
|
|
1872
|
+
_onActionBarKeydown = (e) => {
|
|
1873
|
+
const event = e;
|
|
1874
|
+
const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
|
|
1875
|
+
if (!keys.includes(event.key)) return;
|
|
1876
|
+
const buttons = this._rovingButtons().filter((b2) => !b2.disabled);
|
|
1877
|
+
if (buttons.length === 0) return;
|
|
1878
|
+
const active = document.activeElement;
|
|
1879
|
+
const from = buttons.findIndex((b2) => b2 === active);
|
|
1880
|
+
if (from === -1 && event.key !== "Home" && event.key !== "End") return;
|
|
1881
|
+
const rtl = (this.closest("[dir]")?.getAttribute("dir") ?? document.documentElement.dir) === "rtl";
|
|
1882
|
+
const forward = rtl ? "ArrowLeft" : "ArrowRight";
|
|
1883
|
+
let next;
|
|
1884
|
+
if (event.key === "Home") next = 0;
|
|
1885
|
+
else if (event.key === "End") next = buttons.length - 1;
|
|
1886
|
+
else if (event.key === forward) next = (from + 1) % buttons.length;
|
|
1887
|
+
else next = (from - 1 + buttons.length) % buttons.length;
|
|
1888
|
+
event.preventDefault();
|
|
1889
|
+
for (const button of this._rovingButtons()) button.tabIndex = button === buttons[next] ? 0 : -1;
|
|
1890
|
+
buttons[next]?.focus();
|
|
1891
|
+
};
|
|
1800
1892
|
/**
|
|
1801
1893
|
* An empty action bar is not a bar: with every action off it was still a
|
|
1802
1894
|
* `role="toolbar"` with nothing in it (announced as such), and it still reserved
|
|
@@ -1806,6 +1898,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1806
1898
|
*/
|
|
1807
1899
|
_syncFooterVisibility() {
|
|
1808
1900
|
if (this._actionBarEl) this._actionBarEl.hidden = this._actionBarEl.children.length === 0;
|
|
1901
|
+
this._setRovingIndex();
|
|
1809
1902
|
if (!this._footerEl) return;
|
|
1810
1903
|
const barEmpty = !this._actionBarEl || this._actionBarEl.hidden;
|
|
1811
1904
|
const pickerHidden = !this._branchPickerEl || this._branchPickerEl.hidden;
|
|
@@ -1815,16 +1908,16 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1815
1908
|
/** Append the registered custom action buttons for this bubble's role. */
|
|
1816
1909
|
_appendCustomActions(icons) {
|
|
1817
1910
|
if (!this._actionBarEl) return;
|
|
1818
|
-
for (const
|
|
1819
|
-
const roles =
|
|
1911
|
+
for (const a4 of this._cfg.getActions("bubble")) {
|
|
1912
|
+
const roles = a4.bubble?.roles ?? ["user", "assistant"];
|
|
1820
1913
|
if (!roles.includes(this._role)) continue;
|
|
1821
1914
|
const btn = document.createElement("button");
|
|
1822
1915
|
btn.className = "aparte-btn aparte-btn--icon aparte-action-btn aparte-action-custom";
|
|
1823
|
-
btn.dataset["action"] = `custom:${
|
|
1824
|
-
btn.setAttribute("aria-label",
|
|
1825
|
-
btn.setAttribute("title",
|
|
1826
|
-
const fromProvider = icons[
|
|
1827
|
-
btn.innerHTML =
|
|
1916
|
+
btn.dataset["action"] = `custom:${a4.id}`;
|
|
1917
|
+
btn.setAttribute("aria-label", a4.label);
|
|
1918
|
+
btn.setAttribute("title", a4.label);
|
|
1919
|
+
const fromProvider = icons[a4.icon];
|
|
1920
|
+
btn.innerHTML = a4.icon.startsWith("<") ? a4.icon : typeof fromProvider === "function" ? fromProvider() : a4.iconFallback ?? "";
|
|
1828
1921
|
this._actionBarEl.appendChild(btn);
|
|
1829
1922
|
}
|
|
1830
1923
|
}
|
|
@@ -1868,12 +1961,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1868
1961
|
const cancelLabel = locale.editCancel ?? "Cancel";
|
|
1869
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>`;
|
|
1870
1963
|
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
1871
|
-
btn.addEventListener("click", (
|
|
1964
|
+
btn.addEventListener("click", (e) => this._handleActionClick(e));
|
|
1872
1965
|
});
|
|
1873
1966
|
this._syncFooterVisibility();
|
|
1874
1967
|
}
|
|
1875
|
-
_handleActionClick(
|
|
1876
|
-
const btn =
|
|
1968
|
+
_handleActionClick(e) {
|
|
1969
|
+
const btn = e.currentTarget;
|
|
1877
1970
|
const action = btn.dataset["action"];
|
|
1878
1971
|
const messageId = this.getAttribute("message-id");
|
|
1879
1972
|
if (action?.startsWith("custom:") && messageId) {
|
|
@@ -1890,7 +1983,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1890
1983
|
composed: true,
|
|
1891
1984
|
detail
|
|
1892
1985
|
}));
|
|
1893
|
-
this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(
|
|
1986
|
+
this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e);
|
|
1894
1987
|
return;
|
|
1895
1988
|
}
|
|
1896
1989
|
switch (action) {
|
|
@@ -1898,7 +1991,7 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1898
1991
|
const text = this._content || this._segments.filter((s2) => s2.type !== "thinking").map((s2) => s2.content ?? "").join("\n");
|
|
1899
1992
|
const icons = this._cfg.getIconProvider();
|
|
1900
1993
|
const locale = this._cfg.getLocale();
|
|
1901
|
-
|
|
1994
|
+
copyText(text).then(() => {
|
|
1902
1995
|
btn.innerHTML = icons.check();
|
|
1903
1996
|
btn.setAttribute("data-copied", "");
|
|
1904
1997
|
const copiedLabel = locale.copied ?? locale.copy ?? "Copied";
|
|
@@ -1988,9 +2081,9 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1988
2081
|
this._contentEl.insertAdjacentElement("afterend", input);
|
|
1989
2082
|
input.setValue(this._content);
|
|
1990
2083
|
input.addEventListener("aparte-composer-submit", () => this._exitEditMode(true));
|
|
1991
|
-
input.addEventListener("keydown", (
|
|
1992
|
-
if (
|
|
1993
|
-
|
|
2084
|
+
input.addEventListener("keydown", (e) => {
|
|
2085
|
+
if (e.key === "Escape" && !e.isComposing) {
|
|
2086
|
+
e.preventDefault();
|
|
1994
2087
|
this._exitEditMode(false);
|
|
1995
2088
|
}
|
|
1996
2089
|
});
|
|
@@ -2086,8 +2179,8 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2086
2179
|
disconnectedCallback() {
|
|
2087
2180
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
2088
2181
|
}
|
|
2089
|
-
_onConfigChange = (
|
|
2090
|
-
const detail =
|
|
2182
|
+
_onConfigChange = (e) => {
|
|
2183
|
+
const detail = e.detail;
|
|
2091
2184
|
if (detail?.config && detail.config !== this._cfg) return;
|
|
2092
2185
|
this.innerHTML = "";
|
|
2093
2186
|
this._render();
|
|
@@ -2215,6 +2308,12 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2215
2308
|
_readerInputAt = Number.NEGATIVE_INFINITY;
|
|
2216
2309
|
/** A decrease inside this window after our own scroll, with no gesture, is the browser's. */
|
|
2217
2310
|
_ownScrollWindowMs = 1e3;
|
|
2311
|
+
/** How long after a scroll of ours we keep re-anchoring while the layout churns.
|
|
2312
|
+
* Not `readonly`: a test shortens it to run past the window's end in a few frames. */
|
|
2313
|
+
_settleWindowMs = 400;
|
|
2314
|
+
/** Deadline of the settle in flight (0 = none). Pushed forward, never stacked. */
|
|
2315
|
+
_settleUntil = 0;
|
|
2316
|
+
_settleRafId = null;
|
|
2218
2317
|
/** The keys that scroll a focused scroll container — the only keydowns that are a gesture. */
|
|
2219
2318
|
_scrollKeys = /* @__PURE__ */ new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
|
|
2220
2319
|
_scrollThreshold = 50;
|
|
@@ -2257,12 +2356,43 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2257
2356
|
* render — so a chat already on screen never flipped to RTL until a reload.
|
|
2258
2357
|
* Only OUR config: an instance-scoped change elsewhere must not touch us.
|
|
2259
2358
|
*/
|
|
2260
|
-
_onConfigChange = (
|
|
2261
|
-
const detail =
|
|
2359
|
+
_onConfigChange = (e) => {
|
|
2360
|
+
const detail = e.detail;
|
|
2262
2361
|
if (detail?.config && detail.config !== resolveConfig(this)) return;
|
|
2263
2362
|
this._applyDirection();
|
|
2264
2363
|
this._scrollBtn?.setAttribute("aria-label", resolveConfig(this).t("scrollToBottom"));
|
|
2364
|
+
const surface = this._frameworkManagedDOM ? this : this.querySelector(".aparte-viewport-container");
|
|
2365
|
+
surface?.setAttribute("aria-label", resolveConfig(this).t("transcript"));
|
|
2265
2366
|
};
|
|
2367
|
+
/**
|
|
2368
|
+
* The transcript is a TAB STOP, with a name.
|
|
2369
|
+
*
|
|
2370
|
+
* A scrollable region that no element can hold focus in cannot be scrolled from
|
|
2371
|
+
* the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
|
|
2372
|
+
* caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
|
|
2373
|
+
* transcript — no links, no code blocks, nothing focusable inside — was unreadable
|
|
2374
|
+
* past the first screen for a Safari keyboard user, with no error and nothing on
|
|
2375
|
+
* screen to say so.
|
|
2376
|
+
*
|
|
2377
|
+
* `tabindex="0"` on the surface itself, in BOTH DOM modes. The framework mode
|
|
2378
|
+
* appeared to work already, and only by accident: the scroll button is inside the
|
|
2379
|
+
* host and stays tabbable while it is visually hidden, so Tab happened to land
|
|
2380
|
+
* somewhere that scrolled. A tab stop that exists because a hidden button happens
|
|
2381
|
+
* to sit there is not an affordance, it is a coincidence.
|
|
2382
|
+
*
|
|
2383
|
+
* The name ships WITH the tab stop rather than after it: a focusable `role="log"`
|
|
2384
|
+
* with no accessible name is announced as an unnamed region, which is a worse
|
|
2385
|
+
* answer than no tab stop at all. And the ROLE ships with both: a name on a
|
|
2386
|
+
* generic element is the same defect mirrored — `aria-label` is prohibited on an
|
|
2387
|
+
* element whose role resolves to none, which is what a bare custom element is. The
|
|
2388
|
+
* default mode already writes `role="log"` on the container before this runs, so
|
|
2389
|
+
* the guard here is what gives the framework-mode host the same declaration.
|
|
2390
|
+
*/
|
|
2391
|
+
_nameScrollSurface(el2) {
|
|
2392
|
+
el2.setAttribute("tabindex", "0");
|
|
2393
|
+
if (!el2.hasAttribute("role")) el2.setAttribute("role", "log");
|
|
2394
|
+
el2.setAttribute("aria-label", resolveConfig(this).t("transcript"));
|
|
2395
|
+
}
|
|
2266
2396
|
/** Mirror `locale.direction` onto the scroll container. */
|
|
2267
2397
|
_applyDirection() {
|
|
2268
2398
|
const container = this.querySelector(".aparte-viewport-container");
|
|
@@ -2389,7 +2519,18 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2389
2519
|
* framework's DOM, reads the attribute itself when it connects.
|
|
2390
2520
|
*/
|
|
2391
2521
|
_syncBusy() {
|
|
2392
|
-
|
|
2522
|
+
if (this._frameworkManagedDOM) return;
|
|
2523
|
+
this.setTranscriptBusy(this._streamingMessageId() !== null);
|
|
2524
|
+
}
|
|
2525
|
+
_busy = false;
|
|
2526
|
+
/**
|
|
2527
|
+
* The transcript's read-only-while-streaming flag — `data-busy` on this element and
|
|
2528
|
+
* fanned out to the bubbles it holds. The vanilla path derives it from the
|
|
2529
|
+
* repository (`_syncBusy`); a framework host, whose messages live outside the
|
|
2530
|
+
* repository during a turn, writes it directly from its own streaming id.
|
|
2531
|
+
*/
|
|
2532
|
+
setTranscriptBusy(busy) {
|
|
2533
|
+
this._busy = busy;
|
|
2393
2534
|
this.toggleAttribute("data-busy", busy);
|
|
2394
2535
|
for (const bubble of this.querySelectorAll("aparte-chat-bubble")) {
|
|
2395
2536
|
bubble.setTranscriptBusy?.(busy);
|
|
@@ -2415,10 +2556,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2415
2556
|
this._notifyBubble(messageId, "addSegment", stamped);
|
|
2416
2557
|
this._autoScroll();
|
|
2417
2558
|
}
|
|
2418
|
-
updateSegment(
|
|
2559
|
+
updateSegment(a4, b2, c2) {
|
|
2419
2560
|
const clientForm = c2 === void 0 && typeof b2 === "object";
|
|
2420
|
-
const messageId = clientForm ? this._activeMessageId() :
|
|
2421
|
-
const segmentId = clientForm ?
|
|
2561
|
+
const messageId = clientForm ? this._activeMessageId() : a4;
|
|
2562
|
+
const segmentId = clientForm ? a4 : b2;
|
|
2422
2563
|
const updates = clientForm ? b2 : c2;
|
|
2423
2564
|
if (!messageId) return;
|
|
2424
2565
|
const message = this._repo.getMessageById(messageId);
|
|
@@ -2431,10 +2572,10 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2431
2572
|
this._notifyBubble(messageId, "updateSegment", { segmentId, updates: stamped });
|
|
2432
2573
|
}
|
|
2433
2574
|
}
|
|
2434
|
-
removeSegment(
|
|
2575
|
+
removeSegment(a4, b2) {
|
|
2435
2576
|
const clientForm = b2 === void 0;
|
|
2436
|
-
const messageId = clientForm ? this._activeMessageId() :
|
|
2437
|
-
const segmentId = clientForm ?
|
|
2577
|
+
const messageId = clientForm ? this._activeMessageId() : a4;
|
|
2578
|
+
const segmentId = clientForm ? a4 : b2;
|
|
2438
2579
|
if (!messageId || !segmentId) return;
|
|
2439
2580
|
const message = this._repo.getMessageById(messageId);
|
|
2440
2581
|
if (message?.segments) {
|
|
@@ -2529,7 +2670,9 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2529
2670
|
* which both records the message and creates its bubble element.
|
|
2530
2671
|
*/
|
|
2531
2672
|
addMessage(message) {
|
|
2532
|
-
|
|
2673
|
+
const stored = adoptMessageSegments({ ...message });
|
|
2674
|
+
if (isAwaitingReply(message)) stored.isStreaming = true;
|
|
2675
|
+
this._repo.addOrUpdateMessage(this._repo.headId, stored);
|
|
2533
2676
|
this._pruneRenderedBubbles();
|
|
2534
2677
|
this._syncBusy();
|
|
2535
2678
|
this._autoScroll();
|
|
@@ -2562,6 +2705,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2562
2705
|
const bubble = document.createElement("aparte-chat-bubble");
|
|
2563
2706
|
bubble.setAttribute("message-id", message.id);
|
|
2564
2707
|
bubble.setAttribute("data-role", message.role);
|
|
2708
|
+
if (message.compaction) bubble.setAttribute("data-kind", "compaction");
|
|
2565
2709
|
if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
|
|
2566
2710
|
if (message.content) bubble.setAttribute("content", message.content);
|
|
2567
2711
|
if (isAwaitingReply(message)) {
|
|
@@ -2653,7 +2797,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2653
2797
|
* Triggers a full re-render of the active path.
|
|
2654
2798
|
*/
|
|
2655
2799
|
navigateBranch(messageId, direction) {
|
|
2656
|
-
if (this.
|
|
2800
|
+
if (this._busy) return;
|
|
2657
2801
|
const siblings = this._repo.getBranches(messageId);
|
|
2658
2802
|
const currentIdx = siblings.indexOf(messageId);
|
|
2659
2803
|
if (currentIdx === -1) return;
|
|
@@ -2909,6 +3053,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2909
3053
|
const bubble = document.createElement("aparte-chat-bubble");
|
|
2910
3054
|
bubble.setAttribute("message-id", message.id);
|
|
2911
3055
|
bubble.setAttribute("data-role", message.role);
|
|
3056
|
+
if (message.compaction) bubble.setAttribute("data-kind", "compaction");
|
|
2912
3057
|
if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
|
|
2913
3058
|
if (isAwaitingReply(message)) {
|
|
2914
3059
|
bubble.setAttribute("streaming", "");
|
|
@@ -2968,6 +3113,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
2968
3113
|
container.setAttribute("aria-live", "polite");
|
|
2969
3114
|
container.setAttribute("aria-atomic", "false");
|
|
2970
3115
|
container.setAttribute("aria-relevant", "additions");
|
|
3116
|
+
this._nameScrollSurface(container);
|
|
2971
3117
|
const wrapper = document.createElement("div");
|
|
2972
3118
|
wrapper.className = "aparte-messages-wrapper";
|
|
2973
3119
|
while (this.firstChild) {
|
|
@@ -3013,6 +3159,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3013
3159
|
return;
|
|
3014
3160
|
}
|
|
3015
3161
|
this.classList.add("aparte-viewport--framework");
|
|
3162
|
+
this._nameScrollSurface(this);
|
|
3016
3163
|
const scrollBtn = document.createElement("button");
|
|
3017
3164
|
scrollBtn.className = "aparte-btn aparte-btn--surface aparte-btn--circle aparte-btn--lg aparte-scroll-btn aparte-scroll-btn--hidden";
|
|
3018
3165
|
scrollBtn.setAttribute("type", "button");
|
|
@@ -3069,12 +3216,12 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3069
3216
|
* 1 run in 6: the rebuild's height churn moved scrollTop within the second after
|
|
3070
3217
|
* the click, and the click made that look like the reader's).
|
|
3071
3218
|
*/
|
|
3072
|
-
_noteReaderInput = (
|
|
3073
|
-
if (
|
|
3074
|
-
if (
|
|
3219
|
+
_noteReaderInput = (e) => {
|
|
3220
|
+
if (e.type === "keydown" && !this._scrollKeys.has(e.key)) return;
|
|
3221
|
+
if (e.type === "pointerdown") {
|
|
3075
3222
|
const container = this._container;
|
|
3076
3223
|
if (!container) return;
|
|
3077
|
-
const { clientX } =
|
|
3224
|
+
const { clientX } = e;
|
|
3078
3225
|
const rect = container.getBoundingClientRect();
|
|
3079
3226
|
const rtl = getComputedStyle(container).direction === "rtl";
|
|
3080
3227
|
const inGutter = rtl ? clientX <= rect.right - container.clientWidth : clientX >= rect.left + container.clientWidth;
|
|
@@ -3082,8 +3229,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3082
3229
|
}
|
|
3083
3230
|
this._readerInputAt = performance.now();
|
|
3084
3231
|
};
|
|
3085
|
-
_onBranchNavigate = (
|
|
3086
|
-
const evt =
|
|
3232
|
+
_onBranchNavigate = (e) => {
|
|
3233
|
+
const evt = e;
|
|
3087
3234
|
evt.stopPropagation();
|
|
3088
3235
|
this.navigateBranch(evt.detail.messageId, evt.detail.direction);
|
|
3089
3236
|
};
|
|
@@ -3149,6 +3296,8 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3149
3296
|
this._isAutoScrollEnabled = true;
|
|
3150
3297
|
} else if (readerWentUp) {
|
|
3151
3298
|
this._isAutoScrollEnabled = false;
|
|
3299
|
+
} else if (drop > 1 && settlingOurs && this._isAutoScrollEnabled) {
|
|
3300
|
+
this._settleAtBottom();
|
|
3152
3301
|
}
|
|
3153
3302
|
this._updateScrollButton();
|
|
3154
3303
|
}
|
|
@@ -3156,7 +3305,7 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3156
3305
|
if (!this._container) return;
|
|
3157
3306
|
this._ownScrollAt = performance.now();
|
|
3158
3307
|
this._container.scrollTop = this._container.scrollHeight;
|
|
3159
|
-
this._settleAtBottom(
|
|
3308
|
+
this._settleAtBottom();
|
|
3160
3309
|
}
|
|
3161
3310
|
/**
|
|
3162
3311
|
* Confirm over the next few frames that we actually reached the bottom.
|
|
@@ -3179,19 +3328,38 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3179
3328
|
* A BOUNDED retry, not one corrective frame: a single frame lands on the same
|
|
3180
3329
|
* stale layout and was measured leaving a wider gap than doing nothing. Bounded
|
|
3181
3330
|
* so it always terminates; re-reads `_isAutoScrollEnabled` every frame so a
|
|
3182
|
-
* reader who scrolls away mid-settle is left alone
|
|
3183
|
-
*
|
|
3331
|
+
* reader who scrolls away mid-settle is left alone.
|
|
3332
|
+
*
|
|
3333
|
+
* Bounded by TIME, and it does not stop at the first closed gap — both of those
|
|
3334
|
+
* were the second half of the same bug. It used to be four frames (~64ms on an
|
|
3335
|
+
* idle 60Hz machine) and to return permanently the frame the gap first closed.
|
|
3336
|
+
* A branch swap at the bottom of a long transcript on react-webkit falsified
|
|
3337
|
+
* both at once: the rebuild's scrollable max churned 891 -> 1091 -> 891, the gap
|
|
3338
|
+
* WAS closed for a frame against the tall layout, the chain returned, the height
|
|
3339
|
+
* fell back with WebKit holding `scrollTop` at 720 — and the transcript stood
|
|
3340
|
+
* 171px short with auto-follow still armed. A frame count is a proxy for time
|
|
3341
|
+
* that fails exactly on the slow engine, so the budget is real milliseconds now,
|
|
3342
|
+
* and a closed gap only ends the chain when the window is over. One chain, not
|
|
3343
|
+
* one per call: during a stream every token used to start its own.
|
|
3184
3344
|
*/
|
|
3185
|
-
_settleAtBottom(
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3345
|
+
_settleAtBottom() {
|
|
3346
|
+
this._settleUntil = performance.now() + this._settleWindowMs;
|
|
3347
|
+
if (this._settleRafId !== null) return;
|
|
3348
|
+
const step = () => {
|
|
3349
|
+
this._settleRafId = null;
|
|
3350
|
+
if (!this.isConnected || !this._container || !this._isAutoScrollEnabled) {
|
|
3351
|
+
this._settleUntil = 0;
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
3189
3354
|
const max = this._container.scrollHeight - this._container.clientHeight;
|
|
3190
|
-
if (max - this._container.scrollTop
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3355
|
+
if (max - this._container.scrollTop > 1) {
|
|
3356
|
+
this._ownScrollAt = performance.now();
|
|
3357
|
+
this._container.scrollTop = max;
|
|
3358
|
+
}
|
|
3359
|
+
if (performance.now() < this._settleUntil) this._settleRafId = requestAnimationFrame(step);
|
|
3360
|
+
else this._settleUntil = 0;
|
|
3361
|
+
};
|
|
3362
|
+
this._settleRafId = requestAnimationFrame(step);
|
|
3195
3363
|
}
|
|
3196
3364
|
_smoothScrollToBottom() {
|
|
3197
3365
|
if (!this._container) return;
|
|
@@ -3219,7 +3387,18 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3219
3387
|
* whatever a framework's render timing does in between.
|
|
3220
3388
|
*/
|
|
3221
3389
|
_updateScrollButton() {
|
|
3222
|
-
|
|
3390
|
+
const btn = this._scrollBtn;
|
|
3391
|
+
if (!btn) return;
|
|
3392
|
+
const hidden = this._isAtBottom();
|
|
3393
|
+
if (btn.classList.contains("aparte-scroll-btn--hidden") === hidden && btn.hasAttribute("tabindex") === hidden) return;
|
|
3394
|
+
btn.classList.toggle("aparte-scroll-btn--hidden", hidden);
|
|
3395
|
+
if (hidden) {
|
|
3396
|
+
btn.setAttribute("tabindex", "-1");
|
|
3397
|
+
btn.setAttribute("aria-hidden", "true");
|
|
3398
|
+
} else {
|
|
3399
|
+
btn.removeAttribute("tabindex");
|
|
3400
|
+
btn.removeAttribute("aria-hidden");
|
|
3401
|
+
}
|
|
3223
3402
|
}
|
|
3224
3403
|
/**
|
|
3225
3404
|
* Recalculate the bottom spacer height so the last user message is always
|
|
@@ -3315,6 +3494,11 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3315
3494
|
cancelAnimationFrame(this._spacerRafId);
|
|
3316
3495
|
this._spacerRafId = null;
|
|
3317
3496
|
}
|
|
3497
|
+
if (this._settleRafId !== null) {
|
|
3498
|
+
cancelAnimationFrame(this._settleRafId);
|
|
3499
|
+
this._settleRafId = null;
|
|
3500
|
+
}
|
|
3501
|
+
this._settleUntil = 0;
|
|
3318
3502
|
}
|
|
3319
3503
|
}
|
|
3320
3504
|
if (!customElements.get("aparte-chat-viewport")) {
|
|
@@ -3372,8 +3556,8 @@ class AparteComposer extends HTMLElement {
|
|
|
3372
3556
|
return resolveConfig(this);
|
|
3373
3557
|
}
|
|
3374
3558
|
/** Only OUR config: a change on another chat's instance must not touch us. */
|
|
3375
|
-
_onConfigChangeEvent = (
|
|
3376
|
-
const detail =
|
|
3559
|
+
_onConfigChangeEvent = (e) => {
|
|
3560
|
+
const detail = e.detail;
|
|
3377
3561
|
if (detail?.config && detail.config !== this._cfg) return;
|
|
3378
3562
|
this._onConfigChange();
|
|
3379
3563
|
};
|
|
@@ -3526,9 +3710,8 @@ class AparteComposer extends HTMLElement {
|
|
|
3526
3710
|
* are the user's state and not an action to offer — hiding them would look like
|
|
3527
3711
|
* losing them; and the toolbar, because switching model still does something.
|
|
3528
3712
|
*
|
|
3529
|
-
* The send button is the part the PANEL decides, through `mode`. `'submit'`
|
|
3530
|
-
*
|
|
3531
|
-
* drawn. That third value is what a panel whose options settle on the first click
|
|
3713
|
+
* The send button is the part the PANEL decides, through `mode`. `'submit'`
|
|
3714
|
+
* keeps it; `'none'` says this panel has no act for it and it is not drawn. That third value is what a panel whose options settle on the first click
|
|
3532
3715
|
* needs — a single-choice question, an approval — and until it existed such a
|
|
3533
3716
|
* panel left a permanently disabled button beside options that never routed
|
|
3534
3717
|
* through it. Flip between them at any time with {@link setPanelSubmitEnabled},
|
|
@@ -3631,10 +3814,10 @@ class AparteComposer extends HTMLElement {
|
|
|
3631
3814
|
/**
|
|
3632
3815
|
* Update the send button's state while a panel is active.
|
|
3633
3816
|
*
|
|
3634
|
-
* `mode` moves with it because both change on the same event —
|
|
3635
|
-
*
|
|
3636
|
-
*
|
|
3637
|
-
*
|
|
3817
|
+
* `mode` moves with it because both can change on the same event — opening the
|
|
3818
|
+
* "Other…" field of a panel whose options settle on the click gives the button
|
|
3819
|
+
* an act it did not have — and two separate calls would flash a wrong state
|
|
3820
|
+
* between them.
|
|
3638
3821
|
*/
|
|
3639
3822
|
setPanelSubmitEnabled(enabled, mode) {
|
|
3640
3823
|
if (!this._panelActive) return;
|
|
@@ -3742,8 +3925,8 @@ class AparteComposer extends HTMLElement {
|
|
|
3742
3925
|
/** A window lifecycle event is for THIS composer when neither side is scoped
|
|
3743
3926
|
* (single-instance broadcast) or the target ids match (multi-chat page).
|
|
3744
3927
|
* Without this filter, streaming in one chat flips every composer's state. */
|
|
3745
|
-
_isForThisComposer(
|
|
3746
|
-
const evtTargetId =
|
|
3928
|
+
_isForThisComposer(e) {
|
|
3929
|
+
const evtTargetId = e.detail?.targetId;
|
|
3747
3930
|
return !evtTargetId || !this._ownTargetId() || evtTargetId === this._ownTargetId();
|
|
3748
3931
|
}
|
|
3749
3932
|
/**
|
|
@@ -3786,13 +3969,13 @@ class AparteComposer extends HTMLElement {
|
|
|
3786
3969
|
}
|
|
3787
3970
|
return void 0;
|
|
3788
3971
|
}
|
|
3789
|
-
_handleMessageStart(
|
|
3790
|
-
if (!this._isForThisComposer(
|
|
3972
|
+
_handleMessageStart(e) {
|
|
3973
|
+
if (!this._isForThisComposer(e)) return;
|
|
3791
3974
|
this._streaming = true;
|
|
3792
3975
|
this._emit("streaming-change", { streaming: true });
|
|
3793
3976
|
}
|
|
3794
|
-
_handleMessageDone(
|
|
3795
|
-
if (!this._isForThisComposer(
|
|
3977
|
+
_handleMessageDone(e) {
|
|
3978
|
+
if (!this._isForThisComposer(e)) return;
|
|
3796
3979
|
this._streaming = false;
|
|
3797
3980
|
this._emit("streaming-change", { streaming: false });
|
|
3798
3981
|
if (this._panelActive) this._evictPanel();
|
|
@@ -3950,13 +4133,13 @@ class AparteComposerInput extends HTMLElement {
|
|
|
3950
4133
|
const value = this.getValue();
|
|
3951
4134
|
this._getRoot()?.setValue(value);
|
|
3952
4135
|
}
|
|
3953
|
-
_handleKeydown(
|
|
3954
|
-
if (
|
|
3955
|
-
if (
|
|
4136
|
+
_handleKeydown(e) {
|
|
4137
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
4138
|
+
if (e.key !== "Enter") return;
|
|
3956
4139
|
const submitOnEnter = this._getRoot()?.submitOnEnter ?? true;
|
|
3957
|
-
const submits = submitOnEnter ? !
|
|
4140
|
+
const submits = submitOnEnter ? !e.shiftKey : e.shiftKey;
|
|
3958
4141
|
if (submits) {
|
|
3959
|
-
|
|
4142
|
+
e.preventDefault();
|
|
3960
4143
|
const root = this._getRoot();
|
|
3961
4144
|
if (root) {
|
|
3962
4145
|
if (root.streaming) return;
|
|
@@ -3965,7 +4148,7 @@ class AparteComposerInput extends HTMLElement {
|
|
|
3965
4148
|
this.dispatchEvent(new CustomEvent("aparte-composer-submit", { bubbles: true }));
|
|
3966
4149
|
}
|
|
3967
4150
|
} else {
|
|
3968
|
-
|
|
4151
|
+
e.preventDefault();
|
|
3969
4152
|
if (!this._editor?.textContent) return;
|
|
3970
4153
|
this.ownerDocument?.execCommand("insertLineBreak");
|
|
3971
4154
|
}
|
|
@@ -3976,9 +4159,9 @@ class AparteComposerInput extends HTMLElement {
|
|
|
3976
4159
|
_handleBlur() {
|
|
3977
4160
|
this.classList.remove("aparte-is-focused");
|
|
3978
4161
|
}
|
|
3979
|
-
_handlePaste(
|
|
3980
|
-
|
|
3981
|
-
const cd =
|
|
4162
|
+
_handlePaste(e) {
|
|
4163
|
+
e.preventDefault();
|
|
4164
|
+
const cd = e.clipboardData;
|
|
3982
4165
|
if (!cd) return;
|
|
3983
4166
|
const imageFile = Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile();
|
|
3984
4167
|
if (imageFile) {
|
|
@@ -4128,14 +4311,14 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4128
4311
|
this._syncState();
|
|
4129
4312
|
}
|
|
4130
4313
|
/**
|
|
4131
|
-
* Panel open: this one button now means "
|
|
4132
|
-
* where you are in the form.
|
|
4314
|
+
* Panel open: this one button now means "submit the answer".
|
|
4133
4315
|
*
|
|
4134
4316
|
* The icon has to move with the meaning: it drew a paper plane while the label
|
|
4135
4317
|
* already said "Submit", so it read as "send a message" while it meant "answer
|
|
4136
|
-
* this question".
|
|
4137
|
-
*
|
|
4138
|
-
*
|
|
4318
|
+
* this question". It used to draw a chevron too, while a form had questions
|
|
4319
|
+
* ahead, and turn into a "Next" — a second way to do what the panel's chips do,
|
|
4320
|
+
* and a button whose meaning changed under the pointer. It is disabled instead
|
|
4321
|
+
* until every question has an answer, which is what the chips' marks explain.
|
|
4139
4322
|
*/
|
|
4140
4323
|
_syncPanelState() {
|
|
4141
4324
|
const panel = this._panel;
|
|
@@ -4145,16 +4328,15 @@ class AparteComposerSend extends HTMLElement {
|
|
|
4145
4328
|
this._button.disabled = true;
|
|
4146
4329
|
return;
|
|
4147
4330
|
}
|
|
4148
|
-
const advancing = panel.mode === "advance";
|
|
4149
4331
|
this._button.disabled = !panel.submitEnabled;
|
|
4150
|
-
this._button.innerHTML =
|
|
4151
|
-
const label =
|
|
4332
|
+
this._button.innerHTML = this._getSubmitIcon();
|
|
4333
|
+
const label = cfg.t("submitButton") || "Submit";
|
|
4152
4334
|
this._button.setAttribute("aria-label", label);
|
|
4153
4335
|
this._button.setAttribute("title", label);
|
|
4154
4336
|
this._button.classList.remove("aparte-is-streaming");
|
|
4155
4337
|
}
|
|
4156
|
-
_handleClick(
|
|
4157
|
-
|
|
4338
|
+
_handleClick(e) {
|
|
4339
|
+
e.preventDefault();
|
|
4158
4340
|
this._getRoot()?.submit();
|
|
4159
4341
|
}
|
|
4160
4342
|
_syncState() {
|
|
@@ -4241,8 +4423,8 @@ class AparteComposerCancel extends HTMLElement {
|
|
|
4241
4423
|
);
|
|
4242
4424
|
if (root.streaming && this._button) this._button.hidden = false;
|
|
4243
4425
|
}
|
|
4244
|
-
_handleClick(
|
|
4245
|
-
|
|
4426
|
+
_handleClick(e) {
|
|
4427
|
+
e.preventDefault();
|
|
4246
4428
|
this._getRoot()?.cancel();
|
|
4247
4429
|
}
|
|
4248
4430
|
/**
|
|
@@ -4299,19 +4481,21 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
4299
4481
|
_render(files) {
|
|
4300
4482
|
this.hidden = files.length === 0;
|
|
4301
4483
|
this._revokeUrls();
|
|
4484
|
+
const cfg = resolveConfig(this);
|
|
4485
|
+
const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", file.name);
|
|
4302
4486
|
this.innerHTML = files.map((file) => {
|
|
4303
|
-
const name =
|
|
4304
|
-
const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="
|
|
4487
|
+
const name = escapeHtml(file.name);
|
|
4488
|
+
const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="${escapeAttr(removeLabel(file))}">${cfg.getIcon("close")}</button>`;
|
|
4305
4489
|
if (file.type.startsWith("image/")) {
|
|
4306
4490
|
const url = URL.createObjectURL(file);
|
|
4307
4491
|
this._objectUrls.push(url);
|
|
4308
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${
|
|
4492
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeAttr(url)}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
|
|
4309
4493
|
}
|
|
4310
|
-
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${
|
|
4494
|
+
return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
|
|
4311
4495
|
}).join("");
|
|
4312
4496
|
this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
|
|
4313
|
-
btn.addEventListener("click", (
|
|
4314
|
-
|
|
4497
|
+
btn.addEventListener("click", (e) => {
|
|
4498
|
+
e.stopPropagation();
|
|
4315
4499
|
const root = this._getRoot();
|
|
4316
4500
|
if (root) root.removeAttachment(root.attachments[i2]);
|
|
4317
4501
|
});
|
|
@@ -4330,10 +4514,10 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
4330
4514
|
}));
|
|
4331
4515
|
};
|
|
4332
4516
|
tile.addEventListener("click", open);
|
|
4333
|
-
tile.addEventListener("keydown", (
|
|
4334
|
-
const key =
|
|
4517
|
+
tile.addEventListener("keydown", (e) => {
|
|
4518
|
+
const key = e.key;
|
|
4335
4519
|
if (key !== "Enter" && key !== " ") return;
|
|
4336
|
-
|
|
4520
|
+
e.preventDefault();
|
|
4337
4521
|
open();
|
|
4338
4522
|
});
|
|
4339
4523
|
});
|
|
@@ -4343,9 +4527,6 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
4343
4527
|
const dot = filename.lastIndexOf(".");
|
|
4344
4528
|
return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
|
|
4345
4529
|
}
|
|
4346
|
-
_escape(str) {
|
|
4347
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4348
|
-
}
|
|
4349
4530
|
}
|
|
4350
4531
|
if (!customElements.get("aparte-composer-attachments")) {
|
|
4351
4532
|
customElements.define("aparte-composer-attachments", AparteComposerAttachments);
|
|
@@ -4443,24 +4624,24 @@ class AparteComposerAddAttachment extends HTMLElement {
|
|
|
4443
4624
|
_setupDragDrop() {
|
|
4444
4625
|
const root = this._getRoot();
|
|
4445
4626
|
if (!root) return;
|
|
4446
|
-
const prevent = (
|
|
4447
|
-
|
|
4448
|
-
|
|
4627
|
+
const prevent = (e) => {
|
|
4628
|
+
e.preventDefault();
|
|
4629
|
+
e.stopPropagation();
|
|
4449
4630
|
};
|
|
4450
|
-
const onDragOver = (
|
|
4631
|
+
const onDragOver = (e) => {
|
|
4451
4632
|
if (root.disabled) return;
|
|
4452
|
-
prevent(
|
|
4633
|
+
prevent(e);
|
|
4453
4634
|
root.classList.add("aparte-is-dragover");
|
|
4454
4635
|
};
|
|
4455
|
-
const onDragLeave = (
|
|
4456
|
-
prevent(
|
|
4636
|
+
const onDragLeave = (e) => {
|
|
4637
|
+
prevent(e);
|
|
4457
4638
|
root.classList.remove("aparte-is-dragover");
|
|
4458
4639
|
};
|
|
4459
|
-
const onDrop = (
|
|
4460
|
-
prevent(
|
|
4640
|
+
const onDrop = (e) => {
|
|
4641
|
+
prevent(e);
|
|
4461
4642
|
root.classList.remove("aparte-is-dragover");
|
|
4462
4643
|
if (root.disabled) return;
|
|
4463
|
-
const files =
|
|
4644
|
+
const files = e.dataTransfer?.files;
|
|
4464
4645
|
if (files?.length) this._getRoot()?.addAttachments(files);
|
|
4465
4646
|
};
|
|
4466
4647
|
root.addEventListener("dragover", onDragOver);
|
|
@@ -4591,11 +4772,16 @@ class AparteComposerToolbar extends HTMLElement {
|
|
|
4591
4772
|
if (!customElements.get("aparte-composer-toolbar")) {
|
|
4592
4773
|
customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
|
|
4593
4774
|
}
|
|
4775
|
+
const DAY = 864e5;
|
|
4776
|
+
const MENU_GAP = 4;
|
|
4777
|
+
const VIEWPORT_MARGIN = 8;
|
|
4594
4778
|
class AparteConversationList extends HTMLElement {
|
|
4595
4779
|
_conversations = [];
|
|
4596
4780
|
_activeId = null;
|
|
4781
|
+
_open = null;
|
|
4782
|
+
_renaming = null;
|
|
4597
4783
|
static get observedAttributes() {
|
|
4598
|
-
return ["active-id"];
|
|
4784
|
+
return ["active-id", "no-groups"];
|
|
4599
4785
|
}
|
|
4600
4786
|
// ─── Lifecycle ────────────────────────────────────────────────────────
|
|
4601
4787
|
connectedCallback() {
|
|
@@ -4605,10 +4791,17 @@ class AparteConversationList extends HTMLElement {
|
|
|
4605
4791
|
if (!this.getAttribute("role")) {
|
|
4606
4792
|
this.setAttribute("role", "navigation");
|
|
4607
4793
|
}
|
|
4794
|
+
this.addEventListener("click", this._onClick);
|
|
4795
|
+
this.addEventListener("keydown", this._onKeydown);
|
|
4796
|
+
this.addEventListener("focusout", this._onFocusOut);
|
|
4608
4797
|
this._render();
|
|
4609
4798
|
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
4610
4799
|
}
|
|
4611
4800
|
disconnectedCallback() {
|
|
4801
|
+
this._closeMenu();
|
|
4802
|
+
this.removeEventListener("click", this._onClick);
|
|
4803
|
+
this.removeEventListener("keydown", this._onKeydown);
|
|
4804
|
+
this.removeEventListener("focusout", this._onFocusOut);
|
|
4612
4805
|
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
4613
4806
|
}
|
|
4614
4807
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
@@ -4616,15 +4809,18 @@ class AparteConversationList extends HTMLElement {
|
|
|
4616
4809
|
if (name === "active-id") {
|
|
4617
4810
|
this._activeId = newValue;
|
|
4618
4811
|
this._updateActiveState();
|
|
4812
|
+
} else if (name === "no-groups" && this.isConnected) {
|
|
4813
|
+
this._render();
|
|
4619
4814
|
}
|
|
4620
4815
|
}
|
|
4621
4816
|
/**
|
|
4622
|
-
* Re-render on a locale switch: every row's title fallback
|
|
4623
|
-
*
|
|
4624
|
-
* language until something else happened to re-render it. Only OUR
|
|
4817
|
+
* Re-render on a locale switch: every row's title fallback, the `⋯` button's name
|
|
4818
|
+
* and the group headings come from the locale, so without this the list stayed in
|
|
4819
|
+
* the previous language until something else happened to re-render it. Only OUR
|
|
4820
|
+
* config.
|
|
4625
4821
|
*/
|
|
4626
|
-
_onConfigChange = (
|
|
4627
|
-
const detail =
|
|
4822
|
+
_onConfigChange = (e) => {
|
|
4823
|
+
const detail = e.detail;
|
|
4628
4824
|
if (detail?.config && detail.config !== resolveConfig(this)) return;
|
|
4629
4825
|
this._render();
|
|
4630
4826
|
};
|
|
@@ -4639,94 +4835,387 @@ class AparteConversationList extends HTMLElement {
|
|
|
4639
4835
|
}
|
|
4640
4836
|
// ─── Rendering ────────────────────────────────────────────────────────
|
|
4641
4837
|
_render() {
|
|
4642
|
-
this.
|
|
4643
|
-
this.
|
|
4838
|
+
this._closeMenu();
|
|
4839
|
+
this._renaming = null;
|
|
4840
|
+
this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
|
|
4644
4841
|
}
|
|
4645
|
-
|
|
4842
|
+
_t(key) {
|
|
4646
4843
|
const locale = resolveConfig(this).getLocale();
|
|
4844
|
+
return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
|
|
4845
|
+
}
|
|
4846
|
+
/**
|
|
4847
|
+
* Pinned first, then the calendar buckets every chat product uses, then a heading
|
|
4848
|
+
* per month (newest first), then whatever carries no date. Flat when nothing is
|
|
4849
|
+
* dated or the host asked for `no-groups`: a heading over the only group would say
|
|
4850
|
+
* nothing.
|
|
4851
|
+
*/
|
|
4852
|
+
_groups() {
|
|
4853
|
+
const items = this._conversations;
|
|
4854
|
+
if (this.hasAttribute("no-groups") || !items.some((c2) => typeof c2.updatedAt === "number")) {
|
|
4855
|
+
return [{ key: "all", label: null, items }];
|
|
4856
|
+
}
|
|
4857
|
+
const now = /* @__PURE__ */ new Date();
|
|
4858
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
4859
|
+
const tag = resolveConfig(this).getLocale().tag;
|
|
4860
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
4861
|
+
const push = (key, label, item) => {
|
|
4862
|
+
let g2 = buckets.get(key);
|
|
4863
|
+
if (!g2) {
|
|
4864
|
+
g2 = { key, label, items: [] };
|
|
4865
|
+
buckets.set(key, g2);
|
|
4866
|
+
}
|
|
4867
|
+
g2.items.push(item);
|
|
4868
|
+
};
|
|
4869
|
+
for (const c2 of items) {
|
|
4870
|
+
if (c2.pinnedAt) {
|
|
4871
|
+
push("pinned", this._t("conversationGroupPinned"), c2);
|
|
4872
|
+
continue;
|
|
4873
|
+
}
|
|
4874
|
+
const at = c2.updatedAt;
|
|
4875
|
+
if (typeof at !== "number") {
|
|
4876
|
+
push("undated", null, c2);
|
|
4877
|
+
continue;
|
|
4878
|
+
}
|
|
4879
|
+
if (at >= today) push("today", this._t("conversationGroupToday"), c2);
|
|
4880
|
+
else if (at >= today - DAY) push("yesterday", this._t("conversationGroupYesterday"), c2);
|
|
4881
|
+
else if (at >= today - 7 * DAY) push("week", this._t("conversationGroupWeek"), c2);
|
|
4882
|
+
else if (at >= today - 30 * DAY) push("month", this._t("conversationGroupMonth"), c2);
|
|
4883
|
+
else {
|
|
4884
|
+
const d2 = new Date(at);
|
|
4885
|
+
const sameYear = d2.getFullYear() === now.getFullYear();
|
|
4886
|
+
const raw = new Intl.DateTimeFormat(tag, sameYear ? { month: "long" } : { month: "long", year: "numeric" }).format(d2);
|
|
4887
|
+
const label = raw.charAt(0).toLocaleUpperCase(tag) + raw.slice(1);
|
|
4888
|
+
push(`m-${d2.getFullYear()}-${String(d2.getMonth()).padStart(2, "0")}`, label, c2);
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
const fixed = ["pinned", "today", "yesterday", "week", "month"].map((k2) => buckets.get(k2)).filter((g2) => !!g2);
|
|
4892
|
+
const months = [...buckets.keys()].filter((k2) => k2.startsWith("m-")).sort((a4, b2) => b2.localeCompare(a4)).map((k2) => buckets.get(k2));
|
|
4893
|
+
const undated = buckets.get("undated");
|
|
4894
|
+
return [...fixed, ...months, ...undated ? [undated] : []];
|
|
4895
|
+
}
|
|
4896
|
+
_renderGroup(group) {
|
|
4897
|
+
const rows = group.items.map((conv) => this._renderItem(conv)).join("");
|
|
4898
|
+
if (group.label === null) {
|
|
4899
|
+
return `<div class="aparte-conv-group">${rows}</div>`;
|
|
4900
|
+
}
|
|
4901
|
+
const label = escapeHtml(group.label);
|
|
4902
|
+
return `
|
|
4903
|
+
<div class="aparte-conv-group" role="group" aria-label="${escapeAttr(group.label)}">
|
|
4904
|
+
<div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">${label}</div>
|
|
4905
|
+
${rows}
|
|
4906
|
+
</div>`;
|
|
4907
|
+
}
|
|
4908
|
+
_renderItem(conv) {
|
|
4647
4909
|
const isActive = conv.id === this._activeId;
|
|
4648
|
-
const
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
const
|
|
4655
|
-
const
|
|
4656
|
-
const
|
|
4657
|
-
const
|
|
4658
|
-
const archiveGlyph = resolveConfig(this).getIcon(isArchived ? "unarchive" : "archive");
|
|
4910
|
+
const classes = [
|
|
4911
|
+
"aparte-menu__item aparte-conv-item",
|
|
4912
|
+
isActive ? "aparte-conv-item--active" : "",
|
|
4913
|
+
conv.archivedAt ? "aparte-conv-item--archived" : "",
|
|
4914
|
+
conv.pinnedAt ? "aparte-conv-item--pinned" : ""
|
|
4915
|
+
].filter(Boolean).join(" ");
|
|
4916
|
+
const escapedId = escapeAttr(conv.id);
|
|
4917
|
+
const title = escapeHtml(conv.title || this._t("newChat"));
|
|
4918
|
+
const actionsLabel = escapeAttr(this._t("conversationActions"));
|
|
4919
|
+
const moreGlyph = resolveConfig(this).getIcon("more");
|
|
4659
4920
|
return `
|
|
4660
|
-
<div
|
|
4661
|
-
class="aparte-menu__item aparte-conv-item${activeClass}${archivedClass}"
|
|
4662
|
-
role="button"
|
|
4663
|
-
tabindex="0"
|
|
4664
|
-
data-conv-id="${escapedId}"
|
|
4665
|
-
aria-current="${isActive ? "page" : "false"}"
|
|
4666
|
-
>
|
|
4667
|
-
<span class="aparte-conv-item__title">${escapedTitle}</span>
|
|
4921
|
+
<div class="${escapeAttr(classes)}" data-conv-id="${escapedId}">
|
|
4668
4922
|
<button
|
|
4669
|
-
class="aparte-
|
|
4923
|
+
class="aparte-conv-item__select"
|
|
4670
4924
|
type="button"
|
|
4671
|
-
data-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
tabindex="0"
|
|
4675
|
-
>${archiveGlyph}</button>
|
|
4925
|
+
data-select-id="${escapedId}"
|
|
4926
|
+
aria-current="${isActive ? "page" : "false"}"
|
|
4927
|
+
><span class="aparte-conv-item__title">${title}</span></button>
|
|
4676
4928
|
<button
|
|
4677
|
-
class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-
|
|
4929
|
+
class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__more"
|
|
4678
4930
|
type="button"
|
|
4679
|
-
data-
|
|
4680
|
-
aria-label="${
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
</button>
|
|
4931
|
+
data-more-id="${escapedId}"
|
|
4932
|
+
aria-label="${actionsLabel}"
|
|
4933
|
+
title="${actionsLabel}"
|
|
4934
|
+
aria-haspopup="menu"
|
|
4935
|
+
aria-expanded="false"
|
|
4936
|
+
>${moreGlyph}</button>
|
|
4685
4937
|
</div>`;
|
|
4686
4938
|
}
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4939
|
+
// ─── The row's menu ───────────────────────────────────────────────────
|
|
4940
|
+
_menuMarkup(conv) {
|
|
4941
|
+
const config = resolveConfig(this);
|
|
4942
|
+
const editGlyph = config.getIcon("edit");
|
|
4943
|
+
const pinGlyph = config.getIcon("pin");
|
|
4944
|
+
const archiveGlyph = config.getIcon(conv.archivedAt ? "unarchive" : "archive");
|
|
4945
|
+
const trashGlyph = config.getIcon("trash");
|
|
4946
|
+
const rename = escapeHtml(this._t("renameConversation"));
|
|
4947
|
+
const pin = escapeHtml(this._t(conv.pinnedAt ? "unpinConversation" : "pinConversation"));
|
|
4948
|
+
const archive = escapeHtml(this._t(conv.archivedAt ? "unarchiveConversation" : "archiveConversation"));
|
|
4949
|
+
const remove = escapeHtml(this._t("deleteConversation"));
|
|
4950
|
+
return `
|
|
4951
|
+
<button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="rename">${editGlyph}<span>${rename}</span></button>
|
|
4952
|
+
<button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="pin">${pinGlyph}<span>${pin}</span></button>
|
|
4953
|
+
<button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="archive">${archiveGlyph}<span>${archive}</span></button>
|
|
4954
|
+
<div class="aparte-menu__separator" role="separator"></div>
|
|
4955
|
+
<button type="button" class="aparte-menu__item aparte-conv-menu__item--danger" role="menuitem" data-menu-action="delete">${trashGlyph}<span>${remove}</span></button>`;
|
|
4956
|
+
}
|
|
4957
|
+
_confirmMarkup(conv) {
|
|
4958
|
+
const question = escapeHtml(this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat")));
|
|
4959
|
+
const cancel = escapeHtml(this._t("cancel"));
|
|
4960
|
+
const remove = escapeHtml(this._t("deleteConversation"));
|
|
4961
|
+
return `
|
|
4962
|
+
<div class="aparte-conv-menu__confirm" role="group" aria-label="${escapeAttr(question)}">
|
|
4963
|
+
<p class="aparte-conv-menu__question">${question}</p>
|
|
4964
|
+
<div class="aparte-conv-menu__actions">
|
|
4965
|
+
<button type="button" class="aparte-btn aparte-btn--sm aparte-btn--ghost" data-menu-action="cancel">${cancel}</button>
|
|
4966
|
+
<button type="button" class="aparte-btn aparte-btn--sm aparte-btn--danger aparte-btn--solid" data-menu-action="confirm-delete">${remove}</button>
|
|
4967
|
+
</div>
|
|
4968
|
+
</div>`;
|
|
4690
4969
|
}
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
const
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4970
|
+
_openMenu(row, button) {
|
|
4971
|
+
this._closeMenu();
|
|
4972
|
+
const id = row.dataset["convId"];
|
|
4973
|
+
const conv = this._conversations.find((c2) => c2.id === id);
|
|
4974
|
+
if (!conv) return;
|
|
4975
|
+
const menu = document.createElement("div");
|
|
4976
|
+
menu.className = "aparte-menu aparte-conv-menu";
|
|
4977
|
+
menu.setAttribute("role", "menu");
|
|
4978
|
+
menu.setAttribute("aria-label", conv.title || this._t("newChat"));
|
|
4979
|
+
menu.innerHTML = this._menuMarkup(conv);
|
|
4980
|
+
row.appendChild(menu);
|
|
4981
|
+
button.setAttribute("aria-expanded", "true");
|
|
4982
|
+
this._open = { id, row, button, menu };
|
|
4983
|
+
this._placeMenu(button, menu);
|
|
4984
|
+
menu.querySelector('[role="menuitem"]')?.focus();
|
|
4985
|
+
document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
|
|
4986
|
+
window.addEventListener("scroll", this._onWindowScroll, true);
|
|
4987
|
+
window.addEventListener("resize", this._onWindowScroll);
|
|
4988
|
+
}
|
|
4989
|
+
/**
|
|
4990
|
+
* Fixed, below the button's end edge, flipped above it when the viewport ends
|
|
4991
|
+
* first. Fixed rather than absolute because a sidebar scrolls: an absolute menu on
|
|
4992
|
+
* the last rows would be clipped by the list's own overflow. No anchoring library —
|
|
4993
|
+
* the menu closes on any scroll or resize, which is what makes the arithmetic
|
|
4994
|
+
* safe to do once.
|
|
4995
|
+
*/
|
|
4996
|
+
_placeMenu(button, menu) {
|
|
4997
|
+
const r = button.getBoundingClientRect();
|
|
4998
|
+
const m2 = menu.getBoundingClientRect();
|
|
4999
|
+
let top = r.bottom + MENU_GAP;
|
|
5000
|
+
if (top + m2.height > window.innerHeight - VIEWPORT_MARGIN && r.top - m2.height - MENU_GAP >= VIEWPORT_MARGIN) {
|
|
5001
|
+
top = r.top - m2.height - MENU_GAP;
|
|
5002
|
+
}
|
|
5003
|
+
const rtl = getComputedStyle(this).direction === "rtl";
|
|
5004
|
+
let left = rtl ? r.left : r.right - m2.width;
|
|
5005
|
+
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - m2.width - VIEWPORT_MARGIN));
|
|
5006
|
+
menu.style.top = `${top}px`;
|
|
5007
|
+
menu.style.left = `${left}px`;
|
|
5008
|
+
}
|
|
5009
|
+
_closeMenu(returnFocus = false) {
|
|
5010
|
+
const open = this._open;
|
|
5011
|
+
if (!open) return;
|
|
5012
|
+
this._open = null;
|
|
5013
|
+
open.menu.remove();
|
|
5014
|
+
open.button.setAttribute("aria-expanded", "false");
|
|
5015
|
+
document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
|
|
5016
|
+
window.removeEventListener("scroll", this._onWindowScroll, true);
|
|
5017
|
+
window.removeEventListener("resize", this._onWindowScroll);
|
|
5018
|
+
if (returnFocus && open.button.isConnected) open.button.focus();
|
|
5019
|
+
}
|
|
5020
|
+
_onDocumentPointerDown = (e) => {
|
|
5021
|
+
const open = this._open;
|
|
5022
|
+
if (!open) return;
|
|
5023
|
+
const target = e.target;
|
|
5024
|
+
if (open.menu.contains(target) || open.button.contains(target)) return;
|
|
5025
|
+
this._closeMenu();
|
|
5026
|
+
};
|
|
5027
|
+
_onWindowScroll = () => {
|
|
5028
|
+
this._closeMenu();
|
|
5029
|
+
};
|
|
5030
|
+
_onFocusOut = (e) => {
|
|
5031
|
+
const open = this._open;
|
|
5032
|
+
if (!open) return;
|
|
5033
|
+
const next = e.relatedTarget;
|
|
5034
|
+
if (next && (open.menu.contains(next) || open.button.contains(next))) return;
|
|
5035
|
+
if (open.menu.contains(e.target)) this._closeMenu();
|
|
5036
|
+
};
|
|
5037
|
+
_onMenuAction(action) {
|
|
5038
|
+
const open = this._open;
|
|
5039
|
+
if (!open) return;
|
|
5040
|
+
const conv = this._conversations.find((c2) => c2.id === open.id);
|
|
5041
|
+
if (!conv) {
|
|
5042
|
+
this._closeMenu();
|
|
4703
5043
|
return;
|
|
4704
5044
|
}
|
|
4705
|
-
const
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
5045
|
+
const id = conv.id;
|
|
5046
|
+
switch (action) {
|
|
5047
|
+
case "rename":
|
|
5048
|
+
this._closeMenu();
|
|
5049
|
+
this._startRename(open.row, conv);
|
|
5050
|
+
return;
|
|
5051
|
+
// The two toggles dispatch each name as a LITERAL, in its own branch: the
|
|
5052
|
+
// manifest analyzer records a dispatch whose name is a ternary as an event
|
|
5053
|
+
// with no name (the Angular generator then crashes on it), and one whose
|
|
5054
|
+
// name is a local as an event called after the local.
|
|
5055
|
+
case "pin":
|
|
5056
|
+
this._closeMenu(true);
|
|
5057
|
+
if (conv.pinnedAt) {
|
|
5058
|
+
this.dispatchEvent(new CustomEvent(
|
|
5059
|
+
"aparte-unpin-conversation",
|
|
5060
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
5061
|
+
));
|
|
5062
|
+
} else {
|
|
5063
|
+
this.dispatchEvent(new CustomEvent(
|
|
5064
|
+
"aparte-pin-conversation",
|
|
5065
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
5066
|
+
));
|
|
5067
|
+
}
|
|
5068
|
+
return;
|
|
5069
|
+
case "archive":
|
|
5070
|
+
this._closeMenu(true);
|
|
5071
|
+
if (conv.archivedAt) {
|
|
5072
|
+
this.dispatchEvent(new CustomEvent(
|
|
5073
|
+
"aparte-unarchive-conversation",
|
|
5074
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
5075
|
+
));
|
|
5076
|
+
} else {
|
|
5077
|
+
this.dispatchEvent(new CustomEvent(
|
|
5078
|
+
"aparte-archive-conversation",
|
|
5079
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
5080
|
+
));
|
|
5081
|
+
}
|
|
5082
|
+
return;
|
|
5083
|
+
case "delete":
|
|
5084
|
+
open.menu.innerHTML = this._confirmMarkup(conv);
|
|
5085
|
+
this._placeMenu(open.button, open.menu);
|
|
5086
|
+
open.menu.querySelector('[data-menu-action="cancel"]')?.focus();
|
|
5087
|
+
return;
|
|
5088
|
+
case "cancel":
|
|
5089
|
+
this._closeMenu(true);
|
|
5090
|
+
return;
|
|
5091
|
+
case "confirm-delete":
|
|
5092
|
+
this._closeMenu(true);
|
|
5093
|
+
this.dispatchEvent(new CustomEvent(
|
|
5094
|
+
"aparte-delete-conversation",
|
|
5095
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
5096
|
+
));
|
|
5097
|
+
return;
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
_menuFocusables() {
|
|
5101
|
+
const open = this._open;
|
|
5102
|
+
if (!open) return [];
|
|
5103
|
+
return Array.from(open.menu.querySelectorAll('[role="menuitem"], [data-menu-action]'));
|
|
5104
|
+
}
|
|
5105
|
+
_onMenuKeydown(e) {
|
|
5106
|
+
const items = this._menuFocusables();
|
|
5107
|
+
const index = items.indexOf(document.activeElement);
|
|
5108
|
+
const focusAt = (i2) => {
|
|
5109
|
+
e.preventDefault();
|
|
5110
|
+
items[(i2 + items.length) % items.length]?.focus();
|
|
5111
|
+
};
|
|
5112
|
+
switch (e.key) {
|
|
5113
|
+
case "Escape":
|
|
5114
|
+
e.preventDefault();
|
|
5115
|
+
this._closeMenu(true);
|
|
5116
|
+
return;
|
|
5117
|
+
case "Tab":
|
|
5118
|
+
e.preventDefault();
|
|
5119
|
+
this._closeMenu(true);
|
|
5120
|
+
return;
|
|
5121
|
+
case "ArrowDown":
|
|
5122
|
+
focusAt(index + 1);
|
|
5123
|
+
return;
|
|
5124
|
+
case "ArrowUp":
|
|
5125
|
+
focusAt(index - 1);
|
|
5126
|
+
return;
|
|
5127
|
+
case "Home":
|
|
5128
|
+
focusAt(0);
|
|
5129
|
+
return;
|
|
5130
|
+
case "End":
|
|
5131
|
+
focusAt(items.length - 1);
|
|
5132
|
+
return;
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
// ─── Rename ───────────────────────────────────────────────────────────
|
|
5136
|
+
_startRename(row, conv) {
|
|
5137
|
+
const select = row.querySelector(".aparte-conv-item__select");
|
|
5138
|
+
if (!select) return;
|
|
5139
|
+
const input = document.createElement("input");
|
|
5140
|
+
input.type = "text";
|
|
5141
|
+
input.className = "aparte-field aparte-field--sm aparte-conv-item__input";
|
|
5142
|
+
input.value = conv.title;
|
|
5143
|
+
input.setAttribute("aria-label", this._t("conversationTitle"));
|
|
5144
|
+
input.dataset["renameId"] = conv.id;
|
|
5145
|
+
select.replaceWith(input);
|
|
5146
|
+
this._renaming = { id: conv.id, input, done: false };
|
|
5147
|
+
input.addEventListener("blur", () => this._finishRename(true, false));
|
|
5148
|
+
input.focus();
|
|
5149
|
+
input.select();
|
|
5150
|
+
}
|
|
5151
|
+
/**
|
|
5152
|
+
* One exit for the three ways out (Enter, Escape, leaving the field), guarded so
|
|
5153
|
+
* the blur a removal causes cannot commit a second time. The row is re-rendered
|
|
5154
|
+
* from the array either way: the list shows data, and the host's re-assignment is
|
|
5155
|
+
* what makes the new title appear.
|
|
5156
|
+
*/
|
|
5157
|
+
_finishRename(commit, viaKeyboard) {
|
|
5158
|
+
const renaming = this._renaming;
|
|
5159
|
+
if (!renaming || renaming.done) return;
|
|
5160
|
+
renaming.done = true;
|
|
5161
|
+
const conv = this._conversations.find((c2) => c2.id === renaming.id);
|
|
5162
|
+
const title = renaming.input.value.trim();
|
|
5163
|
+
this._render();
|
|
5164
|
+
if (viaKeyboard) {
|
|
5165
|
+
this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
|
|
5166
|
+
}
|
|
5167
|
+
if (commit && conv && title && title !== conv.title) {
|
|
4709
5168
|
this.dispatchEvent(new CustomEvent(
|
|
4710
|
-
"aparte-
|
|
4711
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5169
|
+
"aparte-rename-conversation",
|
|
5170
|
+
{ detail: { id: conv.id, title }, bubbles: true, composed: true }
|
|
4712
5171
|
));
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
// ─── Events ───────────────────────────────────────────────────────────
|
|
5175
|
+
_onClick = (e) => {
|
|
5176
|
+
const target = e.target;
|
|
5177
|
+
const menuAction = target.closest("[data-menu-action]");
|
|
5178
|
+
if (menuAction && this._open?.menu.contains(menuAction)) {
|
|
5179
|
+
e.stopPropagation();
|
|
5180
|
+
this._onMenuAction(menuAction.dataset["menuAction"]);
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
const more = target.closest("[data-more-id]");
|
|
5184
|
+
if (more) {
|
|
5185
|
+
e.stopPropagation();
|
|
5186
|
+
if (this._open?.button === more) {
|
|
5187
|
+
this._closeMenu(true);
|
|
5188
|
+
} else {
|
|
5189
|
+
this._openMenu(more.closest("[data-conv-id]"), more);
|
|
5190
|
+
}
|
|
4713
5191
|
return;
|
|
4714
5192
|
}
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
5193
|
+
if (target.closest("[data-rename-id]")) return;
|
|
5194
|
+
const select = target.closest("[data-select-id]");
|
|
5195
|
+
if (select) {
|
|
5196
|
+
this._closeMenu();
|
|
4718
5197
|
this.dispatchEvent(new CustomEvent(
|
|
4719
5198
|
"aparte-select-conversation",
|
|
4720
|
-
{ detail: { id }, bubbles: true, composed: true }
|
|
5199
|
+
{ detail: { id: select.dataset["selectId"] }, bubbles: true, composed: true }
|
|
4721
5200
|
));
|
|
4722
5201
|
}
|
|
4723
5202
|
};
|
|
4724
|
-
_onKeydown = (
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
5203
|
+
_onKeydown = (e) => {
|
|
5204
|
+
const target = e.target;
|
|
5205
|
+
if (this._open?.menu.contains(target)) {
|
|
5206
|
+
this._onMenuKeydown(e);
|
|
5207
|
+
return;
|
|
5208
|
+
}
|
|
5209
|
+
if (target.matches("[data-rename-id]")) {
|
|
5210
|
+
if (e.key === "Enter") {
|
|
5211
|
+
e.preventDefault();
|
|
5212
|
+
this._finishRename(true, true);
|
|
5213
|
+
} else if (e.key === "Escape") {
|
|
5214
|
+
e.preventDefault();
|
|
5215
|
+
this._finishRename(false, true);
|
|
5216
|
+
}
|
|
5217
|
+
return;
|
|
5218
|
+
}
|
|
4730
5219
|
};
|
|
4731
5220
|
/** Update active class without full re-render (perf optimisation). */
|
|
4732
5221
|
_updateActiveState() {
|
|
@@ -4734,13 +5223,9 @@ class AparteConversationList extends HTMLElement {
|
|
|
4734
5223
|
items.forEach((el2) => {
|
|
4735
5224
|
const isActive = el2.dataset["convId"] === this._activeId;
|
|
4736
5225
|
el2.classList.toggle("aparte-conv-item--active", isActive);
|
|
4737
|
-
el2.setAttribute("aria-current", isActive ? "page" : "false");
|
|
5226
|
+
el2.querySelector("[data-select-id]")?.setAttribute("aria-current", isActive ? "page" : "false");
|
|
4738
5227
|
});
|
|
4739
5228
|
}
|
|
4740
|
-
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
4741
|
-
_esc(str) {
|
|
4742
|
-
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'");
|
|
4743
|
-
}
|
|
4744
5229
|
}
|
|
4745
5230
|
if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
|
|
4746
5231
|
const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
|
|
@@ -4888,15 +5373,31 @@ const el = (tag, className) => {
|
|
|
4888
5373
|
node.className = className;
|
|
4889
5374
|
return node;
|
|
4890
5375
|
};
|
|
5376
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
5377
|
+
const ringSvg = () => {
|
|
5378
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
5379
|
+
svg.setAttribute("class", "aparte-context__ring");
|
|
5380
|
+
svg.setAttribute("viewBox", "0 0 36 36");
|
|
5381
|
+
for (const part of ["track", "value"]) {
|
|
5382
|
+
const circle = document.createElementNS(SVG_NS, "circle");
|
|
5383
|
+
circle.setAttribute("class", `aparte-context__${part}`);
|
|
5384
|
+
circle.setAttribute("cx", "18");
|
|
5385
|
+
circle.setAttribute("cy", "18");
|
|
5386
|
+
circle.setAttribute("r", "15.5");
|
|
5387
|
+
if (part === "value") circle.setAttribute("pathLength", "100");
|
|
5388
|
+
svg.appendChild(circle);
|
|
5389
|
+
}
|
|
5390
|
+
return svg;
|
|
5391
|
+
};
|
|
4891
5392
|
class AparteContext extends HTMLElement {
|
|
4892
5393
|
static get observedAttributes() {
|
|
4893
|
-
return ["window", "warn", "danger", "target"];
|
|
5394
|
+
return ["window", "warn", "danger", "target", "variant"];
|
|
4894
5395
|
}
|
|
4895
5396
|
_used = null;
|
|
4896
5397
|
_level = null;
|
|
4897
5398
|
_compactRequested = false;
|
|
4898
|
-
_onDone = (
|
|
4899
|
-
const detail =
|
|
5399
|
+
_onDone = (e) => {
|
|
5400
|
+
const detail = e.detail;
|
|
4900
5401
|
if (!detail?.usage || !this._isMine(detail.targetId)) return;
|
|
4901
5402
|
const { usage } = detail;
|
|
4902
5403
|
this._used = usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
|
|
@@ -4905,16 +5406,24 @@ class AparteContext extends HTMLElement {
|
|
|
4905
5406
|
/**
|
|
4906
5407
|
* After a real compaction the context is smaller by an amount nobody has measured
|
|
4907
5408
|
* yet — the next turn's usage will say. Until then the gauge shows nothing rather
|
|
4908
|
-
* than a number it knows to be wrong.
|
|
4909
|
-
* multi-chat page
|
|
5409
|
+
* than a number it knows to be wrong. The event names the chat it compacted, so on a
|
|
5410
|
+
* multi-chat page only that chat's gauge resets; an unnamed one resets every gauge,
|
|
5411
|
+
* and each is right again one turn later.
|
|
4910
5412
|
*/
|
|
4911
|
-
_onCompacted = (
|
|
4912
|
-
const detail =
|
|
5413
|
+
_onCompacted = (e) => {
|
|
5414
|
+
const detail = e.detail;
|
|
5415
|
+
if (!this._isMine(detail?.targetId)) return;
|
|
5416
|
+
this._compactRequested = false;
|
|
4913
5417
|
if (detail?.skipped) return;
|
|
4914
5418
|
this._used = null;
|
|
4915
|
-
this._compactRequested = false;
|
|
4916
5419
|
this._render();
|
|
4917
5420
|
};
|
|
5421
|
+
/** A failed compaction spends the request too; the next `danger` asks again. */
|
|
5422
|
+
_onCompactError = (e) => {
|
|
5423
|
+
const detail = e.detail;
|
|
5424
|
+
if (!this._isMine(detail?.targetId)) return;
|
|
5425
|
+
this._compactRequested = false;
|
|
5426
|
+
};
|
|
4918
5427
|
_onRerender = () => this._render();
|
|
4919
5428
|
/** Tokens the last turn reported, or `null` before the first turn. */
|
|
4920
5429
|
get used() {
|
|
@@ -4931,12 +5440,14 @@ class AparteContext extends HTMLElement {
|
|
|
4931
5440
|
connectedCallback() {
|
|
4932
5441
|
window.addEventListener("aparte-message-done", this._onDone);
|
|
4933
5442
|
window.addEventListener("aparte-compact-done", this._onCompacted);
|
|
5443
|
+
window.addEventListener("aparte-compact-error", this._onCompactError);
|
|
4934
5444
|
window.addEventListener("aparte-config-change", this._onRerender);
|
|
4935
5445
|
this._render();
|
|
4936
5446
|
}
|
|
4937
5447
|
disconnectedCallback() {
|
|
4938
5448
|
window.removeEventListener("aparte-message-done", this._onDone);
|
|
4939
5449
|
window.removeEventListener("aparte-compact-done", this._onCompacted);
|
|
5450
|
+
window.removeEventListener("aparte-compact-error", this._onCompactError);
|
|
4940
5451
|
window.removeEventListener("aparte-config-change", this._onRerender);
|
|
4941
5452
|
}
|
|
4942
5453
|
attributeChangedCallback() {
|
|
@@ -4980,58 +5491,1345 @@ class AparteContext extends HTMLElement {
|
|
|
4980
5491
|
const danger = fraction(this.getAttribute("danger"), 0.9);
|
|
4981
5492
|
const level = ratio >= danger ? "danger" : ratio >= warn ? "warn" : "ok";
|
|
4982
5493
|
this.setAttribute("data-level", level);
|
|
4983
|
-
|
|
5494
|
+
const ring = this.getAttribute("variant") === "ring";
|
|
5495
|
+
let root = this.querySelector(".aparte-context");
|
|
5496
|
+
if (root && root.classList.contains("aparte-context--ring") !== ring) {
|
|
5497
|
+
this.replaceChildren();
|
|
5498
|
+
root = null;
|
|
5499
|
+
}
|
|
5500
|
+
let meter = this.querySelector('[role="meter"]');
|
|
4984
5501
|
let text = this.querySelector(".aparte-context__text");
|
|
4985
|
-
if (!
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
5502
|
+
if (!root || !meter || !text) {
|
|
5503
|
+
root = el("div", ring ? "aparte-context aparte-context--ring" : "aparte-context");
|
|
5504
|
+
meter = ring ? ringSvg() : el("div", "aparte-progress");
|
|
5505
|
+
meter.setAttribute("role", "meter");
|
|
5506
|
+
if (!ring) meter.appendChild(el("div", "aparte-progress__bar"));
|
|
4990
5507
|
text = el("span", "aparte-context__text");
|
|
4991
|
-
root.append(
|
|
5508
|
+
root.append(meter, text);
|
|
4992
5509
|
this.replaceChildren(root);
|
|
4993
5510
|
}
|
|
4994
|
-
const
|
|
5511
|
+
const cfg = resolveConfig(this);
|
|
5512
|
+
const tag = cfg.getLocale().tag || void 0;
|
|
5513
|
+
const format = new Intl.NumberFormat(tag, { notation: "compact", maximumFractionDigits: 1 });
|
|
4995
5514
|
const reading = `${format.format(used)} / ${format.format(capacity)}`;
|
|
4996
|
-
const
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5515
|
+
const percent = Math.round(ratio * 100);
|
|
5516
|
+
const label = cfg.t("contextLabel") || "Context window";
|
|
5517
|
+
if (ring) {
|
|
5518
|
+
const value = meter.querySelector(".aparte-context__value");
|
|
5519
|
+
value?.style.setProperty("--aparte-context-ratio", String(percent));
|
|
5520
|
+
value?.classList.toggle("aparte-context__value--empty", percent === 0);
|
|
5521
|
+
root.title = reading;
|
|
5522
|
+
} else {
|
|
5523
|
+
meter.style.setProperty("--aparte-progress-value", String(percent));
|
|
5524
|
+
}
|
|
5525
|
+
meter.setAttribute("aria-valuemin", "0");
|
|
5526
|
+
meter.setAttribute("aria-valuemax", String(capacity));
|
|
5527
|
+
meter.setAttribute("aria-valuenow", String(used));
|
|
5528
|
+
meter.setAttribute("aria-label", `${label}: ${reading}`);
|
|
5529
|
+
text.textContent = ring ? new Intl.NumberFormat(tag, { style: "percent", maximumFractionDigits: 0 }).format(percent / 100) : reading;
|
|
5003
5530
|
if (level !== this._level) {
|
|
5004
5531
|
this._level = level;
|
|
5005
5532
|
const targetId = this._ownTargetId();
|
|
5006
5533
|
const detail = { level, used, window: capacity, ratio, targetId };
|
|
5007
5534
|
this.dispatchEvent(new CustomEvent("aparte-context-threshold", { detail, bubbles: true, composed: true }));
|
|
5008
|
-
if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
|
|
5009
|
-
this._compactRequested = true;
|
|
5010
|
-
window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId } }));
|
|
5011
|
-
}
|
|
5012
5535
|
if (level !== "danger") this._compactRequested = false;
|
|
5013
5536
|
}
|
|
5537
|
+
if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
|
|
5538
|
+
this._compactRequested = true;
|
|
5539
|
+
window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId: this._ownTargetId() } }));
|
|
5540
|
+
}
|
|
5014
5541
|
}
|
|
5015
5542
|
}
|
|
5016
5543
|
if (!customElements.get("aparte-context")) {
|
|
5017
5544
|
customElements.define("aparte-context", AparteContext);
|
|
5018
5545
|
}
|
|
5546
|
+
const EXCERPT_LENGTH = 60;
|
|
5547
|
+
class AparteScrollRail extends HTMLElement {
|
|
5548
|
+
static get observedAttributes() {
|
|
5549
|
+
return ["target", "every"];
|
|
5550
|
+
}
|
|
5551
|
+
_host = null;
|
|
5552
|
+
_mutations = null;
|
|
5553
|
+
_intersections = null;
|
|
5554
|
+
_rebuildQueued = false;
|
|
5555
|
+
_currentId = null;
|
|
5556
|
+
/** Visible ratio per message id, from the intersection observer. */
|
|
5557
|
+
_visible = /* @__PURE__ */ new Map();
|
|
5558
|
+
_onConfigChange = () => this._rebuild();
|
|
5559
|
+
/** The id of the message the rail currently marks, or `null`. */
|
|
5560
|
+
get currentMessageId() {
|
|
5561
|
+
return this._currentId;
|
|
5562
|
+
}
|
|
5563
|
+
connectedCallback() {
|
|
5564
|
+
if (!this.classList.contains("aparte-scroll-rail")) this.classList.add("aparte-scroll-rail");
|
|
5565
|
+
if (!this.getAttribute("role")) this.setAttribute("role", "navigation");
|
|
5566
|
+
this.addEventListener("click", this._onClick);
|
|
5567
|
+
this.addEventListener("keydown", this._onKeydown);
|
|
5568
|
+
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
5569
|
+
this._attach();
|
|
5570
|
+
}
|
|
5571
|
+
disconnectedCallback() {
|
|
5572
|
+
this._detach();
|
|
5573
|
+
this.removeEventListener("click", this._onClick);
|
|
5574
|
+
this.removeEventListener("keydown", this._onKeydown);
|
|
5575
|
+
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
5576
|
+
}
|
|
5577
|
+
attributeChangedCallback(name) {
|
|
5578
|
+
if (!this.isConnected) return;
|
|
5579
|
+
if (name === "target") this._attach();
|
|
5580
|
+
else this._rebuild();
|
|
5581
|
+
}
|
|
5582
|
+
// ─── Wiring ───────────────────────────────────────────────────────────
|
|
5583
|
+
/** The chat this rail follows: `target`, else the host above it — the gauge's rule. */
|
|
5584
|
+
_findHost() {
|
|
5585
|
+
const target = this.getAttribute("target");
|
|
5586
|
+
if (target) return document.getElementById(target);
|
|
5587
|
+
let node = this.parentElement;
|
|
5588
|
+
while (node) {
|
|
5589
|
+
if (node.tagName.toLowerCase() === "aparte-chat" || node.hasAttribute("data-aparte-chat")) return node;
|
|
5590
|
+
node = node.parentElement;
|
|
5591
|
+
}
|
|
5592
|
+
return null;
|
|
5593
|
+
}
|
|
5594
|
+
_attach() {
|
|
5595
|
+
this._detach();
|
|
5596
|
+
this._host = this._findHost();
|
|
5597
|
+
if (!this._host) {
|
|
5598
|
+
this._rebuild();
|
|
5599
|
+
return;
|
|
5600
|
+
}
|
|
5601
|
+
if (typeof MutationObserver !== "undefined") {
|
|
5602
|
+
this._mutations = new MutationObserver(() => this._queueRebuild());
|
|
5603
|
+
this._mutations.observe(this._host, { childList: true, subtree: true, characterData: true });
|
|
5604
|
+
}
|
|
5605
|
+
this._rebuild();
|
|
5606
|
+
}
|
|
5607
|
+
_detach() {
|
|
5608
|
+
this._mutations?.disconnect();
|
|
5609
|
+
this._mutations = null;
|
|
5610
|
+
this._intersections?.disconnect();
|
|
5611
|
+
this._intersections = null;
|
|
5612
|
+
this._visible.clear();
|
|
5613
|
+
this._host = null;
|
|
5614
|
+
}
|
|
5615
|
+
/** Coalesce a burst of mutations (a streaming reply is one per token) into one rebuild. */
|
|
5616
|
+
_queueRebuild() {
|
|
5617
|
+
if (this._rebuildQueued) return;
|
|
5618
|
+
this._rebuildQueued = true;
|
|
5619
|
+
requestAnimationFrame(() => {
|
|
5620
|
+
this._rebuildQueued = false;
|
|
5621
|
+
this._rebuild();
|
|
5622
|
+
});
|
|
5623
|
+
}
|
|
5624
|
+
_viewport() {
|
|
5625
|
+
return this._host?.querySelector("aparte-chat-viewport") ?? null;
|
|
5626
|
+
}
|
|
5627
|
+
/** The element that scrolls: the vanilla container, or the viewport itself under a wrapper. */
|
|
5628
|
+
_scrollSurface() {
|
|
5629
|
+
const viewport = this._viewport();
|
|
5630
|
+
if (!viewport) return null;
|
|
5631
|
+
return viewport.querySelector(".aparte-viewport-container") ?? viewport;
|
|
5632
|
+
}
|
|
5633
|
+
_bubbles() {
|
|
5634
|
+
const viewport = this._viewport();
|
|
5635
|
+
if (!viewport) return [];
|
|
5636
|
+
const every = this.getAttribute("every") === "message" ? "message" : "user";
|
|
5637
|
+
const all = Array.from(viewport.querySelectorAll("aparte-chat-bubble[message-id]"));
|
|
5638
|
+
return every === "message" ? all : all.filter((b2) => b2.getAttribute("data-role") === "user");
|
|
5639
|
+
}
|
|
5640
|
+
// ─── Rendering ────────────────────────────────────────────────────────
|
|
5641
|
+
/** The first words of a message, for the tick's name. An excerpt, never a summary. */
|
|
5642
|
+
_excerpt(bubble) {
|
|
5643
|
+
const body = bubble.querySelector(".aparte-message-content") ?? bubble;
|
|
5644
|
+
const text = (body.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
5645
|
+
return text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 1).trimEnd()}…` : text;
|
|
5646
|
+
}
|
|
5647
|
+
_tickLabel(bubble) {
|
|
5648
|
+
const locale = resolveConfig(this).getLocale();
|
|
5649
|
+
const role = bubble.getAttribute("data-role") === "user" ? locale.roleNameUser ?? "You" : locale.roleNameAssistant ?? "Assistant";
|
|
5650
|
+
const excerpt = this._excerpt(bubble);
|
|
5651
|
+
return excerpt ? `${role}: ${excerpt}` : role;
|
|
5652
|
+
}
|
|
5653
|
+
_rebuild() {
|
|
5654
|
+
const bubbles = this._bubbles();
|
|
5655
|
+
const locale = resolveConfig(this).getLocale();
|
|
5656
|
+
this.setAttribute("aria-label", locale.scrollRailLabel ?? "Conversation outline");
|
|
5657
|
+
if (bubbles.length < 2) {
|
|
5658
|
+
this.setAttribute("data-empty", "");
|
|
5659
|
+
this.replaceChildren();
|
|
5660
|
+
this._intersections?.disconnect();
|
|
5661
|
+
this._intersections = null;
|
|
5662
|
+
this._visible.clear();
|
|
5663
|
+
this._currentId = null;
|
|
5664
|
+
return;
|
|
5665
|
+
}
|
|
5666
|
+
this.removeAttribute("data-empty");
|
|
5667
|
+
const list = document.createElement("ol");
|
|
5668
|
+
list.className = "aparte-scroll-rail__list";
|
|
5669
|
+
const known = /* @__PURE__ */ new Set();
|
|
5670
|
+
for (const bubble of bubbles) {
|
|
5671
|
+
const id = bubble.getAttribute("message-id");
|
|
5672
|
+
known.add(id);
|
|
5673
|
+
const item = document.createElement("li");
|
|
5674
|
+
const tick = document.createElement("button");
|
|
5675
|
+
tick.type = "button";
|
|
5676
|
+
tick.className = "aparte-scroll-rail__tick";
|
|
5677
|
+
tick.dataset["messageId"] = id;
|
|
5678
|
+
const label = this._tickLabel(bubble);
|
|
5679
|
+
tick.setAttribute("aria-label", label);
|
|
5680
|
+
tick.title = label;
|
|
5681
|
+
if (id === this._currentId) tick.setAttribute("aria-current", "true");
|
|
5682
|
+
item.appendChild(tick);
|
|
5683
|
+
list.appendChild(item);
|
|
5684
|
+
}
|
|
5685
|
+
this.replaceChildren(list);
|
|
5686
|
+
if (this._currentId && !known.has(this._currentId)) this._currentId = null;
|
|
5687
|
+
this._observe(bubbles);
|
|
5688
|
+
}
|
|
5689
|
+
/**
|
|
5690
|
+
* Which tick is current is read, not computed: the scroll surface is the root, and
|
|
5691
|
+
* the bubble with the largest visible share of a reading band near the top wins.
|
|
5692
|
+
* Scroll arithmetic was the alternative, and every scroll-position rule in the
|
|
5693
|
+
* viewport carries a regression note — this one has none to carry.
|
|
5694
|
+
*/
|
|
5695
|
+
_observe(bubbles) {
|
|
5696
|
+
this._intersections?.disconnect();
|
|
5697
|
+
this._intersections = null;
|
|
5698
|
+
this._visible.clear();
|
|
5699
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
5700
|
+
const root = this._scrollSurface();
|
|
5701
|
+
if (!root) return;
|
|
5702
|
+
this._intersections = new IntersectionObserver((entries) => {
|
|
5703
|
+
for (const entry of entries) {
|
|
5704
|
+
const id = entry.target.getAttribute("message-id");
|
|
5705
|
+
if (id) this._visible.set(id, entry.isIntersecting ? entry.intersectionRatio : 0);
|
|
5706
|
+
}
|
|
5707
|
+
this._pickCurrent(bubbles);
|
|
5708
|
+
}, { root, rootMargin: "-15% 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] });
|
|
5709
|
+
for (const bubble of bubbles) this._intersections.observe(bubble);
|
|
5710
|
+
}
|
|
5711
|
+
_pickCurrent(bubbles) {
|
|
5712
|
+
let best = null;
|
|
5713
|
+
let bestRatio = 0;
|
|
5714
|
+
for (const bubble of bubbles) {
|
|
5715
|
+
const id = bubble.getAttribute("message-id");
|
|
5716
|
+
const ratio = this._visible.get(id) ?? 0;
|
|
5717
|
+
if (ratio > bestRatio) {
|
|
5718
|
+
bestRatio = ratio;
|
|
5719
|
+
best = id;
|
|
5720
|
+
}
|
|
5721
|
+
}
|
|
5722
|
+
if (best !== null) this._setCurrent(best);
|
|
5723
|
+
}
|
|
5724
|
+
_setCurrent(id) {
|
|
5725
|
+
if (this._currentId === id) return;
|
|
5726
|
+
this._currentId = id;
|
|
5727
|
+
for (const tick of this.querySelectorAll(".aparte-scroll-rail__tick")) {
|
|
5728
|
+
if (tick.dataset["messageId"] === id) tick.setAttribute("aria-current", "true");
|
|
5729
|
+
else tick.removeAttribute("aria-current");
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
5732
|
+
// ─── Interaction ──────────────────────────────────────────────────────
|
|
5733
|
+
_onClick = (e) => {
|
|
5734
|
+
const tick = e.target.closest(".aparte-scroll-rail__tick");
|
|
5735
|
+
if (!tick || !this.contains(tick)) return;
|
|
5736
|
+
const messageId = tick.dataset["messageId"];
|
|
5737
|
+
if (!messageId) return;
|
|
5738
|
+
this.jumpTo(messageId);
|
|
5739
|
+
};
|
|
5740
|
+
/** The ticks are buttons; the arrows walk them, as in any toolbar. */
|
|
5741
|
+
_onKeydown = (e) => {
|
|
5742
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
|
|
5743
|
+
const ticks = Array.from(this.querySelectorAll(".aparte-scroll-rail__tick"));
|
|
5744
|
+
const index = ticks.indexOf(document.activeElement);
|
|
5745
|
+
if (index === -1) return;
|
|
5746
|
+
e.preventDefault();
|
|
5747
|
+
const next = e.key === "Home" ? 0 : e.key === "End" ? ticks.length - 1 : e.key === "ArrowDown" ? Math.min(ticks.length - 1, index + 1) : Math.max(0, index - 1);
|
|
5748
|
+
ticks[next]?.focus();
|
|
5749
|
+
};
|
|
5750
|
+
/**
|
|
5751
|
+
* Scroll the transcript to a message. Announced first, cancelable; then a
|
|
5752
|
+
* `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
|
|
5753
|
+
*/
|
|
5754
|
+
jumpTo(messageId) {
|
|
5755
|
+
const announced = this.dispatchEvent(new CustomEvent("aparte-scroll-rail-jump", {
|
|
5756
|
+
detail: { messageId },
|
|
5757
|
+
bubbles: true,
|
|
5758
|
+
composed: true,
|
|
5759
|
+
cancelable: true
|
|
5760
|
+
}));
|
|
5761
|
+
if (!announced) return;
|
|
5762
|
+
const viewport = this._viewport();
|
|
5763
|
+
const bubble = viewport?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(messageId)}"]`);
|
|
5764
|
+
if (!bubble) return;
|
|
5765
|
+
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
5766
|
+
bubble.scrollIntoView({ block: "start", behavior: reduced ? "auto" : "smooth" });
|
|
5767
|
+
this._setCurrent(messageId);
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
if (!customElements.get("aparte-scroll-rail")) {
|
|
5771
|
+
customElements.define("aparte-scroll-rail", AparteScrollRail);
|
|
5772
|
+
}
|
|
5773
|
+
const DRAWER_QUERY = "(max-width: 48rem)";
|
|
5774
|
+
class AparteSidebar extends HTMLElement {
|
|
5775
|
+
static get observedAttributes() {
|
|
5776
|
+
return ["collapsed", "breakpoint"];
|
|
5777
|
+
}
|
|
5778
|
+
_media = null;
|
|
5779
|
+
_scrim = null;
|
|
5780
|
+
/** The control that last opened the drawer, to hand the focus back to. */
|
|
5781
|
+
_opener = null;
|
|
5782
|
+
_lastCollapsed = null;
|
|
5783
|
+
/**
|
|
5784
|
+
* `connectedCallback` has run and the element knows what its markup asked for.
|
|
5785
|
+
*
|
|
5786
|
+
* Not `isConnected`: during an UPGRADE the element is already in the document, so
|
|
5787
|
+
* `attributeChangedCallback` fires for every authored attribute — connected, and
|
|
5788
|
+
* before `connectedCallback` — which is the ordinary case for a server-rendered
|
|
5789
|
+
* `<aparte-sidebar collapsed>` upgraded when the module loads. Announcing there
|
|
5790
|
+
* tells the host its sidebar just closed when all that happened is that the markup
|
|
5791
|
+
* was read, and says `drawer: false` because the media query has not run yet.
|
|
5792
|
+
*/
|
|
5793
|
+
_ready = false;
|
|
5794
|
+
/**
|
|
5795
|
+
* The host (or the markup) asked for the column to be folded, so widening the
|
|
5796
|
+
* window must not unfold it. Only a collapse taken OUTSIDE the drawer state counts:
|
|
5797
|
+
* dismissing an overlay says nothing about what a wide window should show.
|
|
5798
|
+
*/
|
|
5799
|
+
_closedByHost = false;
|
|
5800
|
+
/**
|
|
5801
|
+
* `_closedByHost` has been taken from the markup once. A re-parent runs
|
|
5802
|
+
* `connectedCallback` again, and by then `collapsed` may be the BREAKPOINT's own
|
|
5803
|
+
* write — a drawer closes itself on the way in — so reading it back a second time
|
|
5804
|
+
* records the element's own doing as the host's word and the column never reopens.
|
|
5805
|
+
* Like `_initialCaptured` on `<aparte-split>`, it must survive the move.
|
|
5806
|
+
*/
|
|
5807
|
+
_hostIntentSeeded = false;
|
|
5808
|
+
/** Raised around `_applyDrawer`'s own writes, so the breakpoint is never read as a host's intent. */
|
|
5809
|
+
_auto = false;
|
|
5810
|
+
/**
|
|
5811
|
+
* The element wrote `inert`/`aria-hidden` and may take them back. A host that inerts
|
|
5812
|
+
* the sidebar behind its own modal wrote them itself, and `_syncHidden` runs on every
|
|
5813
|
+
* breakpoint re-evaluation: without this it would un-inert the page behind the overlay.
|
|
5814
|
+
* Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
|
|
5815
|
+
*/
|
|
5816
|
+
_ownHidden = false;
|
|
5817
|
+
/** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
|
|
5818
|
+
get collapsed() {
|
|
5819
|
+
return this.hasAttribute("collapsed");
|
|
5820
|
+
}
|
|
5821
|
+
set collapsed(value) {
|
|
5822
|
+
this.toggleAttribute("collapsed", value);
|
|
5823
|
+
}
|
|
5824
|
+
/** True while the window is under the breakpoint and the sidebar is a drawer. */
|
|
5825
|
+
get drawer() {
|
|
5826
|
+
return this.hasAttribute("data-drawer");
|
|
5827
|
+
}
|
|
5828
|
+
connectedCallback() {
|
|
5829
|
+
if (!this.classList.contains("aparte-sidebar")) this.classList.add("aparte-sidebar");
|
|
5830
|
+
if (!this.getAttribute("role")) this.setAttribute("role", "complementary");
|
|
5831
|
+
this._relabel();
|
|
5832
|
+
if (!this._hostIntentSeeded) {
|
|
5833
|
+
this._closedByHost = this.collapsed;
|
|
5834
|
+
this._hostIntentSeeded = true;
|
|
5835
|
+
}
|
|
5836
|
+
this.addEventListener("input", this._onInput);
|
|
5837
|
+
document.addEventListener("keydown", this._onKeydown);
|
|
5838
|
+
document.addEventListener("click", this._onDocumentClick);
|
|
5839
|
+
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
5840
|
+
this._watchBreakpoint();
|
|
5841
|
+
this._syncHidden();
|
|
5842
|
+
this._lastCollapsed = this.collapsed;
|
|
5843
|
+
this._ready = true;
|
|
5844
|
+
}
|
|
5845
|
+
/**
|
|
5846
|
+
* The drawer's media query, from the `breakpoint` attribute: a length (`48rem`, the
|
|
5847
|
+
* default, or `640px`), or `none` for a column that never becomes a drawer — which
|
|
5848
|
+
* a narrow host that has room for it wants, and a documentation frame needs.
|
|
5849
|
+
*/
|
|
5850
|
+
_watchBreakpoint() {
|
|
5851
|
+
this._media?.removeEventListener("change", this._onMediaChange);
|
|
5852
|
+
this._media = null;
|
|
5853
|
+
const attr = (this.getAttribute("breakpoint") ?? "").trim();
|
|
5854
|
+
if (attr === "none") {
|
|
5855
|
+
this._applyDrawer(false);
|
|
5856
|
+
return;
|
|
5857
|
+
}
|
|
5858
|
+
if (typeof matchMedia !== "function") return;
|
|
5859
|
+
const query = attr ? `(max-width: ${attr})` : DRAWER_QUERY;
|
|
5860
|
+
this._media = matchMedia(query);
|
|
5861
|
+
this._media.addEventListener("change", this._onMediaChange);
|
|
5862
|
+
this._applyDrawer(this._media.matches);
|
|
5863
|
+
}
|
|
5864
|
+
disconnectedCallback() {
|
|
5865
|
+
this.removeEventListener("input", this._onInput);
|
|
5866
|
+
document.removeEventListener("keydown", this._onKeydown);
|
|
5867
|
+
document.removeEventListener("click", this._onDocumentClick);
|
|
5868
|
+
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
5869
|
+
this._media?.removeEventListener("change", this._onMediaChange);
|
|
5870
|
+
this._media = null;
|
|
5871
|
+
this._ready = false;
|
|
5872
|
+
}
|
|
5873
|
+
attributeChangedCallback(name) {
|
|
5874
|
+
if (!this._ready) return;
|
|
5875
|
+
if (name === "breakpoint") {
|
|
5876
|
+
this._watchBreakpoint();
|
|
5877
|
+
return;
|
|
5878
|
+
}
|
|
5879
|
+
if (name !== "collapsed") return;
|
|
5880
|
+
const collapsed = this.collapsed;
|
|
5881
|
+
if (collapsed === this._lastCollapsed) return;
|
|
5882
|
+
this._lastCollapsed = collapsed;
|
|
5883
|
+
if (!this._auto && !this.drawer) this._closedByHost = collapsed;
|
|
5884
|
+
this._syncScrim();
|
|
5885
|
+
this._syncHidden();
|
|
5886
|
+
if (collapsed && this.drawer && this._opener?.isConnected) {
|
|
5887
|
+
this._opener.focus();
|
|
5888
|
+
this._opener = null;
|
|
5889
|
+
} else if (!collapsed && this.drawer) {
|
|
5890
|
+
this._focusFirst();
|
|
5891
|
+
}
|
|
5892
|
+
this.dispatchEvent(new CustomEvent("aparte-sidebar-toggle", {
|
|
5893
|
+
detail: { collapsed, drawer: this.drawer },
|
|
5894
|
+
bubbles: true,
|
|
5895
|
+
composed: true
|
|
5896
|
+
}));
|
|
5897
|
+
}
|
|
5898
|
+
// ─── Public API ───────────────────────────────────────────────────────
|
|
5899
|
+
/** Open the sidebar. `opener` is the control to hand the focus back to when it closes. */
|
|
5900
|
+
open(opener) {
|
|
5901
|
+
if (opener) this._opener = opener;
|
|
5902
|
+
this.collapsed = false;
|
|
5903
|
+
}
|
|
5904
|
+
close() {
|
|
5905
|
+
this.collapsed = true;
|
|
5906
|
+
}
|
|
5907
|
+
toggle(opener) {
|
|
5908
|
+
if (this.collapsed) this.open(opener);
|
|
5909
|
+
else this.close();
|
|
5910
|
+
}
|
|
5911
|
+
// ─── Drawer ───────────────────────────────────────────────────────────
|
|
5912
|
+
_onMediaChange = (e) => this._applyDrawer(e.matches);
|
|
5913
|
+
/**
|
|
5914
|
+
* Entering the drawer state closes the sidebar: a narrow window that opens on an
|
|
5915
|
+
* overlay covering the chat is the wrong first screen. Leaving it reopens, since
|
|
5916
|
+
* a wide window has room for the column — unless the host had collapsed it before.
|
|
5917
|
+
*/
|
|
5918
|
+
_applyDrawer(drawer) {
|
|
5919
|
+
const was = this.drawer;
|
|
5920
|
+
this.toggleAttribute("data-drawer", drawer);
|
|
5921
|
+
this._auto = true;
|
|
5922
|
+
try {
|
|
5923
|
+
if (drawer && !was) this.collapsed = true;
|
|
5924
|
+
else if (!drawer && was && !this._closedByHost) this.collapsed = false;
|
|
5925
|
+
} finally {
|
|
5926
|
+
this._auto = false;
|
|
5927
|
+
}
|
|
5928
|
+
this._syncScrim();
|
|
5929
|
+
this._syncHidden();
|
|
5930
|
+
}
|
|
5931
|
+
/**
|
|
5932
|
+
* A collapsed sidebar is invisible — folded to nothing in the flow, slid off the
|
|
5933
|
+
* screen as a drawer — so it must not keep its tab stops or its place in the
|
|
5934
|
+
* accessibility tree. `inert` covers focus and the pointer, `aria-hidden` covers
|
|
5935
|
+
* the readers that predate it; a browser without `inert` still gets the second.
|
|
5936
|
+
*
|
|
5937
|
+
* It removes only what it wrote. Both are standard global attributes a host sets
|
|
5938
|
+
* itself — inerting the sidebar behind its own modal is the ordinary pattern — and
|
|
5939
|
+
* this runs on every breakpoint re-evaluation, so clearing them unconditionally lets
|
|
5940
|
+
* an unrelated resize re-expose the page under an overlay. Same convention as
|
|
5941
|
+
* `_relabel`, which leaves a host-authored `aria-label` alone.
|
|
5942
|
+
*
|
|
5943
|
+
* Neither is declared `@attr`, and `data-drawer` is not the precedent: a `@attr` on a
|
|
5944
|
+
* STANDARD name displaces the wrappers' own prop for it — React's JSX props are
|
|
5945
|
+
* `Omit<HTMLAttributes, keyof T>`, so declaring `inert` here would retype
|
|
5946
|
+
* `<aparte-sidebar inert={busy}>` out of existence. The class docblock says it in prose.
|
|
5947
|
+
*/
|
|
5948
|
+
_syncHidden() {
|
|
5949
|
+
if (this.collapsed) {
|
|
5950
|
+
this.toggleAttribute("inert", true);
|
|
5951
|
+
this.setAttribute("aria-hidden", "true");
|
|
5952
|
+
this._ownHidden = true;
|
|
5953
|
+
} else if (this._ownHidden) {
|
|
5954
|
+
this.removeAttribute("inert");
|
|
5955
|
+
this.removeAttribute("aria-hidden");
|
|
5956
|
+
this._ownHidden = false;
|
|
5957
|
+
}
|
|
5958
|
+
}
|
|
5959
|
+
/**
|
|
5960
|
+
* The drawer covers the page, so the focus has to follow it in — otherwise the next
|
|
5961
|
+
* Tab walks the transcript under the overlay. The host owns the children, so this
|
|
5962
|
+
* takes the first thing that can hold focus and leaves the rest alone; a drawer with
|
|
5963
|
+
* nothing focusable in it keeps the focus where it was, which is the honest outcome.
|
|
5964
|
+
*/
|
|
5965
|
+
_focusFirst() {
|
|
5966
|
+
this.querySelector(FOCUSABLE)?.focus();
|
|
5967
|
+
}
|
|
5968
|
+
_syncScrim() {
|
|
5969
|
+
const wanted = this.drawer && !this.collapsed;
|
|
5970
|
+
if (wanted && !this._scrim) {
|
|
5971
|
+
this._scrim = document.createElement("div");
|
|
5972
|
+
this._scrim.className = "aparte-sidebar__scrim";
|
|
5973
|
+
this._scrim.setAttribute("aria-hidden", "true");
|
|
5974
|
+
this._scrim.addEventListener("click", () => this.close());
|
|
5975
|
+
this.appendChild(this._scrim);
|
|
5976
|
+
} else if (!wanted && this._scrim) {
|
|
5977
|
+
this._scrim.remove();
|
|
5978
|
+
this._scrim = null;
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
5981
|
+
_onKeydown = (e) => {
|
|
5982
|
+
if (e.key === "Escape" && this.drawer && !this.collapsed) {
|
|
5983
|
+
e.preventDefault();
|
|
5984
|
+
this.close();
|
|
5985
|
+
}
|
|
5986
|
+
};
|
|
5987
|
+
/** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
|
|
5988
|
+
_onDocumentClick = (e) => {
|
|
5989
|
+
const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
|
|
5990
|
+
if (!control) return;
|
|
5991
|
+
const named = control.getAttribute("data-aparte-sidebar-toggle");
|
|
5992
|
+
if (named) {
|
|
5993
|
+
if (named !== this.id) return;
|
|
5994
|
+
} else if (nearestSidebar(control) !== this) {
|
|
5995
|
+
return;
|
|
5996
|
+
}
|
|
5997
|
+
this.toggle(control);
|
|
5998
|
+
};
|
|
5999
|
+
// ─── Search ───────────────────────────────────────────────────────────
|
|
6000
|
+
_onInput = (e) => {
|
|
6001
|
+
const input = e.target;
|
|
6002
|
+
if (!input?.matches?.("[data-aparte-sidebar-search]")) return;
|
|
6003
|
+
this.filter(input.value);
|
|
6004
|
+
};
|
|
6005
|
+
/**
|
|
6006
|
+
* Hide the conversation rows whose title does not contain `query`, and the date
|
|
6007
|
+
* groups left empty. Case- and accent-insensitive. An empty query shows everything.
|
|
6008
|
+
*/
|
|
6009
|
+
filter(query) {
|
|
6010
|
+
const needle = fold(query);
|
|
6011
|
+
for (const list of this.querySelectorAll("aparte-conversation-list")) {
|
|
6012
|
+
for (const row of list.querySelectorAll("[data-conv-id]")) {
|
|
6013
|
+
const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
|
|
6014
|
+
row.hidden = needle !== "" && !fold(title).includes(needle);
|
|
6015
|
+
}
|
|
6016
|
+
for (const group of list.querySelectorAll(".aparte-conv-group")) {
|
|
6017
|
+
group.hidden = needle !== "" && !Array.from(group.querySelectorAll("[data-conv-id]")).some((r) => !r.hidden);
|
|
6018
|
+
}
|
|
6019
|
+
}
|
|
6020
|
+
}
|
|
6021
|
+
// ─── Locale ───────────────────────────────────────────────────────────
|
|
6022
|
+
_onConfigChange = () => this._relabel();
|
|
6023
|
+
_relabel() {
|
|
6024
|
+
if (this.hasAttribute("aria-label") && this.dataset["ownLabel"] !== "locale") return;
|
|
6025
|
+
const locale = resolveConfig(this).getLocale();
|
|
6026
|
+
this.setAttribute("aria-label", locale.sidebarLabel ?? "Conversations");
|
|
6027
|
+
this.dataset["ownLabel"] = "locale";
|
|
6028
|
+
}
|
|
6029
|
+
}
|
|
6030
|
+
const FOCUSABLE = [
|
|
6031
|
+
"a[href]",
|
|
6032
|
+
"button:not([disabled])",
|
|
6033
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
6034
|
+
"select:not([disabled])",
|
|
6035
|
+
"textarea:not([disabled])",
|
|
6036
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
6037
|
+
].join(",");
|
|
6038
|
+
function nearestSidebar(control) {
|
|
6039
|
+
return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
6040
|
+
}
|
|
6041
|
+
function fold(text) {
|
|
6042
|
+
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
6043
|
+
}
|
|
6044
|
+
if (!customElements.get("aparte-sidebar")) {
|
|
6045
|
+
customElements.define("aparte-sidebar", AparteSidebar);
|
|
6046
|
+
}
|
|
6047
|
+
function clamp(value, min, max) {
|
|
6048
|
+
if (!Number.isFinite(value)) return min;
|
|
6049
|
+
return value < min ? min : value > max ? max : value;
|
|
6050
|
+
}
|
|
6051
|
+
function nextPosition(step) {
|
|
6052
|
+
const start = Number.isFinite(step.startPercent) ? step.startPercent : 0;
|
|
6053
|
+
if (!Number.isFinite(step.containerPx) || step.containerPx <= 0) return clamp(start, 0, 100);
|
|
6054
|
+
const inverted = (!step.vertical && step.rtl) !== (step.primaryEnd === true);
|
|
6055
|
+
const sign = inverted ? -1 : 1;
|
|
6056
|
+
const moved = sign * step.deltaPx * 100 / step.containerPx;
|
|
6057
|
+
return clamp(start + moved, 0, 100);
|
|
6058
|
+
}
|
|
6059
|
+
function keyDelta(key, shift, rtl, vertical, primaryEnd = false) {
|
|
6060
|
+
if (key === "Home") return -100;
|
|
6061
|
+
if (key === "End") return 100;
|
|
6062
|
+
const step = (shift ? 10 : 1) * (primaryEnd ? -1 : 1);
|
|
6063
|
+
if (vertical) {
|
|
6064
|
+
if (key === "ArrowUp") return -step;
|
|
6065
|
+
if (key === "ArrowDown") return step;
|
|
6066
|
+
return null;
|
|
6067
|
+
}
|
|
6068
|
+
if (key === "ArrowLeft") return rtl ? step : -step;
|
|
6069
|
+
if (key === "ArrowRight") return rtl ? -step : step;
|
|
6070
|
+
return null;
|
|
6071
|
+
}
|
|
6072
|
+
const STACK_QUERY = "(max-width: 48rem)";
|
|
6073
|
+
const DEFAULT_POSITION = 38;
|
|
6074
|
+
const SCRIM_RETIRE_MS = 300;
|
|
6075
|
+
function toPercent(raw, fallback) {
|
|
6076
|
+
const value = Number.parseFloat(raw ?? "");
|
|
6077
|
+
if (!Number.isFinite(value)) return fallback;
|
|
6078
|
+
return value < 0 ? 0 : value > 100 ? 100 : value;
|
|
6079
|
+
}
|
|
6080
|
+
class AparteSplit extends HTMLElement {
|
|
6081
|
+
static get observedAttributes() {
|
|
6082
|
+
return ["position", "orientation", "primary", "collapsed", "breakpoint", "pane", "disabled", "label"];
|
|
6083
|
+
}
|
|
6084
|
+
_handle = null;
|
|
6085
|
+
_scrim = null;
|
|
6086
|
+
_media = null;
|
|
6087
|
+
_observer = null;
|
|
6088
|
+
/** The pending `_measureBounds()` from a resize, batched to one per frame. */
|
|
6089
|
+
_measureRafId = null;
|
|
6090
|
+
/** The host size the last resize tick measured, so an unchanged one does nothing. */
|
|
6091
|
+
_observedPx = 0;
|
|
6092
|
+
/** Ids for the panes `aria-controls` names — a counter, the way the primitives do it. */
|
|
6093
|
+
static _paneIdSeq = 0;
|
|
6094
|
+
/** The live position. The attribute holds it only after a commit. */
|
|
6095
|
+
_position = DEFAULT_POSITION;
|
|
6096
|
+
/** What `reset()` — and a double-click on the seam — goes back to. */
|
|
6097
|
+
_initialPosition = DEFAULT_POSITION;
|
|
6098
|
+
/**
|
|
6099
|
+
* `_initialPosition` has been taken from the markup once. A re-parent runs
|
|
6100
|
+
* `connectedCallback` again, and by then the attribute holds the LAST COMMIT, not
|
|
6101
|
+
* what the author wrote: capturing a second time makes a framework re-render, a tab
|
|
6102
|
+
* switch or a drag-and-drop of the panel redefine what "reset" means.
|
|
6103
|
+
*/
|
|
6104
|
+
_initialCaptured = false;
|
|
6105
|
+
/** The size the primary pane had before it was collapsed, for the second Enter. */
|
|
6106
|
+
_preCollapsePosition = null;
|
|
6107
|
+
/** Committed values, so a reflection of our own does not read as a host's change. */
|
|
6108
|
+
_lastPosition = null;
|
|
6109
|
+
_lastCollapsed = null;
|
|
6110
|
+
_lastPane = null;
|
|
6111
|
+
/** What an attribute-driven commit blames. Raised around a key that changes state. */
|
|
6112
|
+
_source = "api";
|
|
6113
|
+
/**
|
|
6114
|
+
* `connectedCallback` has run and the element knows what its markup asked for.
|
|
6115
|
+
*
|
|
6116
|
+
* Not `isConnected`: during an UPGRADE the element is already in the document, so
|
|
6117
|
+
* `attributeChangedCallback` fires for every authored attribute — connected, and
|
|
6118
|
+
* before `connectedCallback` — which is the ordinary case for a server-rendered
|
|
6119
|
+
* `<aparte-split position="38">` upgraded when the module loads. Committing there
|
|
6120
|
+
* measures a layout the element has not set up yet (no `data-stacked`, so on a
|
|
6121
|
+
* phone the probe reads the stacked pane and writes `position="84"`), reflects that
|
|
6122
|
+
* over the authored number, and sends it to whatever the host persists.
|
|
6123
|
+
*/
|
|
6124
|
+
_ready = false;
|
|
6125
|
+
_dragging = false;
|
|
6126
|
+
_captured = false;
|
|
6127
|
+
/** The gesture actually moved the seam. A press that did not commits nothing. */
|
|
6128
|
+
_moved = false;
|
|
6129
|
+
_pointerId = null;
|
|
6130
|
+
_dragStartPercent = DEFAULT_POSITION;
|
|
6131
|
+
_dragOriginPx = 0;
|
|
6132
|
+
_dragContainerPx = 0;
|
|
6133
|
+
/** A key is down and has moved the seam; its keyup is the commit. */
|
|
6134
|
+
_keying = false;
|
|
6135
|
+
// ─── Properties ───────────────────────────────────────────────────────
|
|
6136
|
+
/** The primary pane's size, as a percentage of the container. Setting it commits. */
|
|
6137
|
+
get position() {
|
|
6138
|
+
return this._position;
|
|
6139
|
+
}
|
|
6140
|
+
set position(value) {
|
|
6141
|
+
const next = toPercent(String(value), this._position);
|
|
6142
|
+
if (this.isConnected && next === this._lastPosition) return;
|
|
6143
|
+
this._position = next;
|
|
6144
|
+
this._setLive(next);
|
|
6145
|
+
if (this.isConnected) this._commit("api");
|
|
6146
|
+
}
|
|
6147
|
+
/** The container's axis: `horizontal` (side by side) or `vertical` (stacked). */
|
|
6148
|
+
get orientation() {
|
|
6149
|
+
return this.getAttribute("orientation") === "vertical" ? "vertical" : "horizontal";
|
|
6150
|
+
}
|
|
6151
|
+
set orientation(value) {
|
|
6152
|
+
this.setAttribute("orientation", value);
|
|
6153
|
+
}
|
|
6154
|
+
/** Which pane `position` sizes. */
|
|
6155
|
+
get primary() {
|
|
6156
|
+
return this.getAttribute("primary") === "end" ? "end" : "start";
|
|
6157
|
+
}
|
|
6158
|
+
set primary(value) {
|
|
6159
|
+
this.setAttribute("primary", value);
|
|
6160
|
+
}
|
|
6161
|
+
/** The primary pane is folded to its minimum. */
|
|
6162
|
+
get collapsed() {
|
|
6163
|
+
return this.hasAttribute("collapsed");
|
|
6164
|
+
}
|
|
6165
|
+
set collapsed(value) {
|
|
6166
|
+
this.toggleAttribute("collapsed", value);
|
|
6167
|
+
}
|
|
6168
|
+
/** Which pane is shown while the split is stacked. */
|
|
6169
|
+
get pane() {
|
|
6170
|
+
return this.getAttribute("pane") === "end" ? "end" : "start";
|
|
6171
|
+
}
|
|
6172
|
+
set pane(value) {
|
|
6173
|
+
this.setAttribute("pane", value);
|
|
6174
|
+
}
|
|
6175
|
+
/** No drag, no keys, no tab stop. The seam stays drawn. */
|
|
6176
|
+
get disabled() {
|
|
6177
|
+
return this.hasAttribute("disabled");
|
|
6178
|
+
}
|
|
6179
|
+
set disabled(value) {
|
|
6180
|
+
this.toggleAttribute("disabled", value);
|
|
6181
|
+
}
|
|
6182
|
+
/**
|
|
6183
|
+
* True while the split is showing one pane. Read-only.
|
|
6184
|
+
*
|
|
6185
|
+
* Both routes into that state count. `data-stacked` is the one this element writes
|
|
6186
|
+
* from its own breakpoint; `.aparte-split--only-start` / `--only-end` are the CSS
|
|
6187
|
+
* route a host takes when it owns its breakpoints and sets `breakpoint="none"`. The
|
|
6188
|
+
* sheet gives the two byte-identical rules, so the element has to read them the same
|
|
6189
|
+
* way — every guard downstream keys on this getter, and a split stacked by the class
|
|
6190
|
+
* alone would measure a one-track grid and commit `position="100"`.
|
|
6191
|
+
*/
|
|
6192
|
+
get stacked() {
|
|
6193
|
+
return this.hasAttribute("data-stacked") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
|
|
6194
|
+
}
|
|
6195
|
+
// ─── Public API ───────────────────────────────────────────────────────
|
|
6196
|
+
/** Fold the primary pane to its minimum, remembering the size it had. */
|
|
6197
|
+
collapse() {
|
|
6198
|
+
this.collapsed = true;
|
|
6199
|
+
}
|
|
6200
|
+
/** Unfold it, back to the size it had before it collapsed. */
|
|
6201
|
+
expand() {
|
|
6202
|
+
this.collapsed = false;
|
|
6203
|
+
}
|
|
6204
|
+
toggleCollapse() {
|
|
6205
|
+
this.collapsed = !this.collapsed;
|
|
6206
|
+
}
|
|
6207
|
+
/** Show one pane, which only has a visible effect while the split is stacked. */
|
|
6208
|
+
showPane(which) {
|
|
6209
|
+
this.pane = which;
|
|
6210
|
+
}
|
|
6211
|
+
/**
|
|
6212
|
+
* Back to the position the split was FIRST connected with — a re-parent restores
|
|
6213
|
+
* that, it does not redefine it. What a double-click on the seam does.
|
|
6214
|
+
*/
|
|
6215
|
+
reset() {
|
|
6216
|
+
this._position = this._initialPosition;
|
|
6217
|
+
this._setLive(this._position);
|
|
6218
|
+
if (this.isConnected) this._commit("api");
|
|
6219
|
+
}
|
|
6220
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────
|
|
6221
|
+
connectedCallback() {
|
|
6222
|
+
if (!this.classList.contains("aparte-split")) this.classList.add("aparte-split");
|
|
6223
|
+
this._handle = this._ensureHandle();
|
|
6224
|
+
const attr = this.getAttribute("position");
|
|
6225
|
+
if (attr !== null) {
|
|
6226
|
+
this._position = toPercent(attr, DEFAULT_POSITION);
|
|
6227
|
+
this._setLive(this._position);
|
|
6228
|
+
} else {
|
|
6229
|
+
this._position = toPercent(
|
|
6230
|
+
getComputedStyle(this).getPropertyValue("--aparte-split-position"),
|
|
6231
|
+
DEFAULT_POSITION
|
|
6232
|
+
);
|
|
6233
|
+
}
|
|
6234
|
+
if (!this._initialCaptured) {
|
|
6235
|
+
this._initialPosition = this._position;
|
|
6236
|
+
this._initialCaptured = true;
|
|
6237
|
+
}
|
|
6238
|
+
if (this.collapsed) {
|
|
6239
|
+
if (this._lastCollapsed !== true) this._preCollapsePosition = this._position;
|
|
6240
|
+
this._position = 0;
|
|
6241
|
+
this._setLive(0);
|
|
6242
|
+
}
|
|
6243
|
+
this._lastPosition = this._position;
|
|
6244
|
+
this._lastCollapsed = this.collapsed;
|
|
6245
|
+
this._lastPane = this.pane;
|
|
6246
|
+
this._stampHandle();
|
|
6247
|
+
this._relabel();
|
|
6248
|
+
this._measureBounds();
|
|
6249
|
+
this._handle.addEventListener("pointerdown", this._onPointerDown);
|
|
6250
|
+
this._handle.addEventListener("dblclick", this._onDoubleClick);
|
|
6251
|
+
this._handle.addEventListener("focusout", this._onFocusOut);
|
|
6252
|
+
this.addEventListener("keydown", this._onKeydown);
|
|
6253
|
+
this.addEventListener("keyup", this._onKeyup);
|
|
6254
|
+
document.addEventListener("click", this._onDocumentClick);
|
|
6255
|
+
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
6256
|
+
this._watchBreakpoint();
|
|
6257
|
+
this._watchSize();
|
|
6258
|
+
this._ready = true;
|
|
6259
|
+
}
|
|
6260
|
+
disconnectedCallback() {
|
|
6261
|
+
this._handle?.removeEventListener("pointerdown", this._onPointerDown);
|
|
6262
|
+
this._handle?.removeEventListener("dblclick", this._onDoubleClick);
|
|
6263
|
+
this._handle?.removeEventListener("focusout", this._onFocusOut);
|
|
6264
|
+
this.removeEventListener("keydown", this._onKeydown);
|
|
6265
|
+
this.removeEventListener("keyup", this._onKeyup);
|
|
6266
|
+
document.removeEventListener("click", this._onDocumentClick);
|
|
6267
|
+
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
6268
|
+
this._releasePointer();
|
|
6269
|
+
this._media?.removeEventListener("change", this._onMediaChange);
|
|
6270
|
+
this._media = null;
|
|
6271
|
+
this._observer?.disconnect();
|
|
6272
|
+
this._observer = null;
|
|
6273
|
+
if (this._measureRafId !== null) {
|
|
6274
|
+
cancelAnimationFrame(this._measureRafId);
|
|
6275
|
+
this._measureRafId = null;
|
|
6276
|
+
}
|
|
6277
|
+
this._removeScrim();
|
|
6278
|
+
this._handle?.removeAttribute("data-dragging");
|
|
6279
|
+
this._dragging = false;
|
|
6280
|
+
this._keying = false;
|
|
6281
|
+
this._ready = false;
|
|
6282
|
+
}
|
|
6283
|
+
attributeChangedCallback(name) {
|
|
6284
|
+
if (!this._ready) return;
|
|
6285
|
+
switch (name) {
|
|
6286
|
+
case "position": {
|
|
6287
|
+
const next = toPercent(this.getAttribute("position"), this._position);
|
|
6288
|
+
if (next === this._lastPosition) return;
|
|
6289
|
+
this._position = next;
|
|
6290
|
+
this._setLive(next);
|
|
6291
|
+
this._commit();
|
|
6292
|
+
return;
|
|
6293
|
+
}
|
|
6294
|
+
case "collapsed": {
|
|
6295
|
+
const collapsed = this.collapsed;
|
|
6296
|
+
if (collapsed === this._lastCollapsed) return;
|
|
6297
|
+
this._lastCollapsed = collapsed;
|
|
6298
|
+
if (collapsed) {
|
|
6299
|
+
this._preCollapsePosition = this._position;
|
|
6300
|
+
this._position = 0;
|
|
6301
|
+
} else {
|
|
6302
|
+
this._position = this._preCollapsePosition ?? this._initialPosition;
|
|
6303
|
+
}
|
|
6304
|
+
this._setLive(this._position);
|
|
6305
|
+
this._commit();
|
|
6306
|
+
return;
|
|
6307
|
+
}
|
|
6308
|
+
case "pane": {
|
|
6309
|
+
const pane = this.pane;
|
|
6310
|
+
if (pane === this._lastPane) return;
|
|
6311
|
+
this._lastPane = pane;
|
|
6312
|
+
this._commit();
|
|
6313
|
+
return;
|
|
6314
|
+
}
|
|
6315
|
+
case "orientation":
|
|
6316
|
+
case "primary":
|
|
6317
|
+
this._stampHandle();
|
|
6318
|
+
this._measureBounds();
|
|
6319
|
+
return;
|
|
6320
|
+
case "disabled":
|
|
6321
|
+
if (this.disabled && this._dragging) this._endDrag("pointer", this._dragStartPercent);
|
|
6322
|
+
this._stampHandle();
|
|
6323
|
+
return;
|
|
6324
|
+
case "breakpoint":
|
|
6325
|
+
this._watchBreakpoint();
|
|
6326
|
+
return;
|
|
6327
|
+
case "label":
|
|
6328
|
+
this._relabel();
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
// ─── Structure ────────────────────────────────────────────────────────
|
|
6332
|
+
/**
|
|
6333
|
+
* The seam. An author-written `.aparte-split__handle` is ADOPTED — a second one
|
|
6334
|
+
* would be a second tab stop drawn over the first — otherwise one is inserted
|
|
6335
|
+
* between the first two children, which is where a two-pane grid needs it.
|
|
6336
|
+
*
|
|
6337
|
+
* An adopted one is MOVED there if the author put it elsewhere. The sheet is
|
|
6338
|
+
* positional — three tracks with the seam in the middle, and the stacked rules hide
|
|
6339
|
+
* `:nth-child(1)` / `:nth-child(3)` — so a handle written first would take the
|
|
6340
|
+
* primary pane's track and make stacking hide the wrong child.
|
|
6341
|
+
*/
|
|
6342
|
+
_ensureHandle() {
|
|
6343
|
+
const own = Array.from(this.children).find(
|
|
6344
|
+
(child) => child instanceof HTMLElement && child.classList.contains("aparte-split__handle")
|
|
6345
|
+
);
|
|
6346
|
+
if (own) {
|
|
6347
|
+
if (this.children[1] !== own) {
|
|
6348
|
+
const second2 = Array.from(this.children).filter((child) => child !== own)[1] ?? null;
|
|
6349
|
+
this.insertBefore(own, second2);
|
|
6350
|
+
}
|
|
6351
|
+
return own;
|
|
6352
|
+
}
|
|
6353
|
+
const handle = document.createElement("div");
|
|
6354
|
+
handle.className = "aparte-split__handle";
|
|
6355
|
+
const second = this.children[1] ?? null;
|
|
6356
|
+
if (second) this.insertBefore(handle, second);
|
|
6357
|
+
else this.appendChild(handle);
|
|
6358
|
+
return handle;
|
|
6359
|
+
}
|
|
6360
|
+
/** The panes: every element child that is not the seam. */
|
|
6361
|
+
_panes() {
|
|
6362
|
+
return Array.from(this.children).filter(
|
|
6363
|
+
(child) => child instanceof HTMLElement && !child.classList.contains("aparte-split__handle")
|
|
6364
|
+
);
|
|
6365
|
+
}
|
|
6366
|
+
_primaryPane() {
|
|
6367
|
+
const panes = this._panes();
|
|
6368
|
+
return (this.primary === "end" ? panes[panes.length - 1] : panes[0]) ?? null;
|
|
6369
|
+
}
|
|
6370
|
+
/**
|
|
6371
|
+
* The APG's window splitter, plus the two states that take it out of the tab order.
|
|
6372
|
+
*
|
|
6373
|
+
* `aria-orientation` is the INVERSE of `orientation`: the attribute names the
|
|
6374
|
+
* separator's own axis, so a split whose panes sit side by side has a VERTICAL
|
|
6375
|
+
* separator between them.
|
|
6376
|
+
*/
|
|
6377
|
+
_stampHandle() {
|
|
6378
|
+
const handle = this._handle;
|
|
6379
|
+
if (!handle) return;
|
|
6380
|
+
handle.setAttribute("role", "separator");
|
|
6381
|
+
handle.setAttribute("aria-orientation", this.orientation === "vertical" ? "horizontal" : "vertical");
|
|
6382
|
+
if (this.disabled || this.stacked) handle.removeAttribute("tabindex");
|
|
6383
|
+
else handle.setAttribute("tabindex", "0");
|
|
6384
|
+
handle.setAttribute("aria-valuenow", String(Math.round(this._achievedPercent())));
|
|
6385
|
+
const pane = this._primaryPane();
|
|
6386
|
+
if (pane) {
|
|
6387
|
+
if (!pane.id) pane.id = `aparte-split-pane-${++AparteSplit._paneIdSeq}`;
|
|
6388
|
+
handle.setAttribute("aria-controls", pane.id);
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
// ─── Measurement ──────────────────────────────────────────────────────
|
|
6392
|
+
_measure(el2) {
|
|
6393
|
+
const box = el2.getBoundingClientRect();
|
|
6394
|
+
return this.orientation === "vertical" ? box.height : box.width;
|
|
6395
|
+
}
|
|
6396
|
+
/**
|
|
6397
|
+
* What the pane ACTUALLY got, which is not what was asked for: the CSS clamp between
|
|
6398
|
+
* `--aparte-split-min` and `--aparte-split-max` has the last word, and announcing the
|
|
6399
|
+
* requested value instead is how a splitter comes to tell a screen reader 5% while
|
|
6400
|
+
* the pane sits at 22%. Without layout (jsdom, `display: none`, before first paint)
|
|
6401
|
+
* the request is all there is, so it stands.
|
|
6402
|
+
*
|
|
6403
|
+
* While STACKED there is nothing to read: one pane is `display: none` and the other
|
|
6404
|
+
* spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
|
|
6405
|
+
* Measuring it there is how tapping "Preview" on a phone used to write `position="0"`
|
|
6406
|
+
* into the attribute, the event and the host's storage.
|
|
6407
|
+
*/
|
|
6408
|
+
_achievedPercent() {
|
|
6409
|
+
if (this.stacked) return this._position;
|
|
6410
|
+
const total = this._measure(this);
|
|
6411
|
+
const pane = this._primaryPane();
|
|
6412
|
+
if (!pane || !(total > 0)) return this._position;
|
|
6413
|
+
return Math.max(0, Math.min(100, this._measure(pane) / total * 100));
|
|
6414
|
+
}
|
|
6415
|
+
/**
|
|
6416
|
+
* The range the seam can actually reach, written onto the separator: park the pane
|
|
6417
|
+
* at 0% and read it, park it at 100% and read it, put the position back. Two forced
|
|
6418
|
+
* layouts in one synchronous task, so nothing paints in between.
|
|
6419
|
+
*
|
|
6420
|
+
* It also reconciles `aria-valuenow`, because the two have to be one story: the
|
|
6421
|
+
* requested percentage can sit outside the clamped range the probe just announced —
|
|
6422
|
+
* with a `--aparte-split-min` of `20rem`, `position="38"` on a 700px container asks
|
|
6423
|
+
* for 38 and gets ~46 — and a separator whose value is below its own minimum is the
|
|
6424
|
+
* unclamped-announcement bug this probe exists to prevent, just at rest. `_position`
|
|
6425
|
+
* is left alone: it is the REQUESTED value, and `reset()` means it.
|
|
6426
|
+
*
|
|
6427
|
+
* Gated on `!_dragging`: a ResizeObserver tick mid-gesture would move the pane to
|
|
6428
|
+
* both ends under the pointer. Gated on `!stacked` for the reason
|
|
6429
|
+
* `_achievedPercent()` is: a pane that spans everything measures the same at 0% and
|
|
6430
|
+
* at 100%, which would announce a range of one number.
|
|
6431
|
+
*/
|
|
6432
|
+
_measureBounds() {
|
|
6433
|
+
const handle = this._handle;
|
|
6434
|
+
if (!handle || this._dragging || this.stacked) return;
|
|
6435
|
+
const pane = this._primaryPane();
|
|
6436
|
+
const total = this._measure(this);
|
|
6437
|
+
if (!pane || !(total > 0)) {
|
|
6438
|
+
handle.setAttribute("aria-valuemin", "0");
|
|
6439
|
+
handle.setAttribute("aria-valuemax", "100");
|
|
6440
|
+
return;
|
|
6441
|
+
}
|
|
6442
|
+
const saved = this.style.getPropertyValue("--aparte-split-position");
|
|
6443
|
+
this.style.setProperty("--aparte-split-position", "0%");
|
|
6444
|
+
const min = this._measure(pane) / total * 100;
|
|
6445
|
+
this.style.setProperty("--aparte-split-position", "100%");
|
|
6446
|
+
const max = this._measure(pane) / total * 100;
|
|
6447
|
+
if (saved) this.style.setProperty("--aparte-split-position", saved);
|
|
6448
|
+
else this.style.removeProperty("--aparte-split-position");
|
|
6449
|
+
handle.setAttribute("aria-valuemin", String(Math.round(min)));
|
|
6450
|
+
handle.setAttribute("aria-valuemax", String(Math.round(max)));
|
|
6451
|
+
handle.setAttribute("aria-valuenow", String(Math.round(this._achievedPercent())));
|
|
6452
|
+
}
|
|
6453
|
+
/**
|
|
6454
|
+
* Re-measure when the container's size changes — and only then. The probe writes the
|
|
6455
|
+
* host's `style` three times, and the reflection is commit-only precisely because a
|
|
6456
|
+
* `MutationObserver({ attributes: true })` (the docs' preview frame has one) reposts
|
|
6457
|
+
* a frame height per mutation, which resizes the split, which re-ticks this observer.
|
|
6458
|
+
* So: ignore a tick that did not move the size, and batch the rest to one per frame.
|
|
6459
|
+
*/
|
|
6460
|
+
_watchSize() {
|
|
6461
|
+
if (typeof ResizeObserver !== "function") return;
|
|
6462
|
+
this._observer = new ResizeObserver(() => {
|
|
6463
|
+
const px = this._measure(this);
|
|
6464
|
+
if (Math.abs(px - this._observedPx) <= 1) return;
|
|
6465
|
+
this._observedPx = px;
|
|
6466
|
+
if (this._measureRafId !== null) return;
|
|
6467
|
+
this._measureRafId = requestAnimationFrame(() => {
|
|
6468
|
+
this._measureRafId = null;
|
|
6469
|
+
this._measureBounds();
|
|
6470
|
+
});
|
|
6471
|
+
});
|
|
6472
|
+
this._observer.observe(this);
|
|
6473
|
+
}
|
|
6474
|
+
// ─── The one write, and the one event ─────────────────────────────────
|
|
6475
|
+
_setLive(value) {
|
|
6476
|
+
this.style.setProperty("--aparte-split-position", `${Math.round(value * 1e3) / 1e3}%`);
|
|
6477
|
+
}
|
|
6478
|
+
/**
|
|
6479
|
+
* The commit: reflect, announce, dispatch — once. Everything downstream reads the
|
|
6480
|
+
* ACHIEVED size, so the attribute, `aria-valuenow` and the detail are one number.
|
|
6481
|
+
*
|
|
6482
|
+
* There is no per-frame event on purpose. A bubbling, composed CustomEvent per
|
|
6483
|
+
* pointermove is a reconciler in the drag loop, and no pane in this repo re-lays
|
|
6484
|
+
* itself out in JS — they resize in CSS. The trigger for adding
|
|
6485
|
+
* `aparte-split-resizing` is a consumer whose pane needs a JS measurement mid-drag.
|
|
6486
|
+
*/
|
|
6487
|
+
_commit(source = this._source) {
|
|
6488
|
+
const position = Math.round(this._achievedPercent());
|
|
6489
|
+
this._position = position;
|
|
6490
|
+
this._setLive(position);
|
|
6491
|
+
this._lastPosition = position;
|
|
6492
|
+
if (this.getAttribute("position") !== String(position)) this.setAttribute("position", String(position));
|
|
6493
|
+
this._handle?.setAttribute("aria-valuenow", String(position));
|
|
6494
|
+
this.dispatchEvent(
|
|
6495
|
+
new CustomEvent("aparte-split-resize", {
|
|
6496
|
+
detail: {
|
|
6497
|
+
position,
|
|
6498
|
+
collapsed: this.collapsed,
|
|
6499
|
+
stacked: this.stacked,
|
|
6500
|
+
pane: this.pane,
|
|
6501
|
+
orientation: this.orientation,
|
|
6502
|
+
source
|
|
6503
|
+
},
|
|
6504
|
+
bubbles: true,
|
|
6505
|
+
composed: true
|
|
6506
|
+
})
|
|
6507
|
+
);
|
|
6508
|
+
}
|
|
6509
|
+
// ─── Pointer ──────────────────────────────────────────────────────────
|
|
6510
|
+
/** The computed direction, never `document.dir`: a host can flip one subtree. */
|
|
6511
|
+
_rtl() {
|
|
6512
|
+
return getComputedStyle(this).direction === "rtl";
|
|
6513
|
+
}
|
|
6514
|
+
_onPointerDown = (event) => {
|
|
6515
|
+
if (this.disabled || this.stacked || this._dragging) return;
|
|
6516
|
+
if (event.button > 0) return;
|
|
6517
|
+
event.preventDefault();
|
|
6518
|
+
this._handle?.focus();
|
|
6519
|
+
this._dragging = true;
|
|
6520
|
+
this._captured = false;
|
|
6521
|
+
this._moved = false;
|
|
6522
|
+
this._pointerId = event.pointerId;
|
|
6523
|
+
this._dragStartPercent = this._position;
|
|
6524
|
+
this._dragOriginPx = this.orientation === "vertical" ? event.clientY : event.clientX;
|
|
6525
|
+
this._dragContainerPx = this._measure(this);
|
|
6526
|
+
this._handle?.setAttribute("data-dragging", "");
|
|
6527
|
+
this._addScrim();
|
|
6528
|
+
window.addEventListener("pointermove", this._onPointerMove);
|
|
6529
|
+
window.addEventListener("pointerup", this._onPointerUp);
|
|
6530
|
+
window.addEventListener("pointercancel", this._onPointerCancel);
|
|
6531
|
+
};
|
|
6532
|
+
/**
|
|
6533
|
+
* Only the pointer that started the gesture. On a hybrid device a hovering pen or a
|
|
6534
|
+
* stray touch delivers a `pointermove` with `buttons === 0`, which would otherwise
|
|
6535
|
+
* fall into the release branch below and end someone else's mouse drag.
|
|
6536
|
+
*/
|
|
6537
|
+
_isOurPointer(event) {
|
|
6538
|
+
return this._pointerId === null || event.pointerId === this._pointerId;
|
|
6539
|
+
}
|
|
6540
|
+
_onPointerMove = (event) => {
|
|
6541
|
+
if (!this._dragging || !this._isOurPointer(event)) return;
|
|
6542
|
+
if (event.buttons === 0) {
|
|
6543
|
+
this._endDrag("pointer");
|
|
6544
|
+
return;
|
|
6545
|
+
}
|
|
6546
|
+
if (!this._captured && this._pointerId !== null) {
|
|
6547
|
+
this._captured = true;
|
|
6548
|
+
try {
|
|
6549
|
+
this._handle?.setPointerCapture(this._pointerId);
|
|
6550
|
+
} catch {
|
|
6551
|
+
}
|
|
6552
|
+
}
|
|
6553
|
+
const point = this.orientation === "vertical" ? event.clientY : event.clientX;
|
|
6554
|
+
this._position = nextPosition({
|
|
6555
|
+
startPercent: this._dragStartPercent,
|
|
6556
|
+
deltaPx: point - this._dragOriginPx,
|
|
6557
|
+
containerPx: this._dragContainerPx,
|
|
6558
|
+
rtl: this._rtl(),
|
|
6559
|
+
vertical: this.orientation === "vertical",
|
|
6560
|
+
primaryEnd: this.primary === "end"
|
|
6561
|
+
});
|
|
6562
|
+
this._moved = true;
|
|
6563
|
+
this._setLive(this._position);
|
|
6564
|
+
};
|
|
6565
|
+
_onPointerUp = (event) => {
|
|
6566
|
+
if (this._isOurPointer(event)) this._endDrag("pointer");
|
|
6567
|
+
};
|
|
6568
|
+
_onPointerCancel = (event) => {
|
|
6569
|
+
if (this._isOurPointer(event)) this._endDrag("pointer");
|
|
6570
|
+
};
|
|
6571
|
+
_endDrag(source, restore) {
|
|
6572
|
+
if (!this._dragging) return;
|
|
6573
|
+
const moved = this._moved;
|
|
6574
|
+
this._dragging = false;
|
|
6575
|
+
this._moved = false;
|
|
6576
|
+
this._releasePointer();
|
|
6577
|
+
this._handle?.removeAttribute("data-dragging");
|
|
6578
|
+
this._removeScrim(!moved);
|
|
6579
|
+
if (restore !== void 0) {
|
|
6580
|
+
this._position = restore;
|
|
6581
|
+
this._setLive(restore);
|
|
6582
|
+
}
|
|
6583
|
+
if (!moved && restore === void 0) return;
|
|
6584
|
+
this._measureBounds();
|
|
6585
|
+
this._commit(source);
|
|
6586
|
+
}
|
|
6587
|
+
_releasePointer() {
|
|
6588
|
+
window.removeEventListener("pointermove", this._onPointerMove);
|
|
6589
|
+
window.removeEventListener("pointerup", this._onPointerUp);
|
|
6590
|
+
window.removeEventListener("pointercancel", this._onPointerCancel);
|
|
6591
|
+
if (this._captured && this._pointerId !== null) {
|
|
6592
|
+
try {
|
|
6593
|
+
this._handle?.releasePointerCapture(this._pointerId);
|
|
6594
|
+
} catch {
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
this._captured = false;
|
|
6598
|
+
this._pointerId = null;
|
|
6599
|
+
}
|
|
6600
|
+
/**
|
|
6601
|
+
* The drag overlay: a fixed, transparent child of the HANDLE, alive for one gesture.
|
|
6602
|
+
* Topmost, so it swallows an iframe pane's hit-testing — the case this exists for —
|
|
6603
|
+
* and paints the drag cursor across the page. A child of the handle rather than of
|
|
6604
|
+
* the body so `click`, and therefore `dblclick`, still dispatches at the handle.
|
|
6605
|
+
*/
|
|
6606
|
+
_addScrim() {
|
|
6607
|
+
if (this._scrim || !this._handle) return;
|
|
6608
|
+
const scrim = document.createElement("div");
|
|
6609
|
+
scrim.className = "aparte-split__scrim";
|
|
6610
|
+
scrim.setAttribute("aria-hidden", "true");
|
|
6611
|
+
this._handle.appendChild(scrim);
|
|
6612
|
+
this._scrim = scrim;
|
|
6613
|
+
}
|
|
6614
|
+
/**
|
|
6615
|
+
* Take the overlay off.
|
|
6616
|
+
*
|
|
6617
|
+
* `retire` is the press that never moved — the one a click, and then a double-click,
|
|
6618
|
+
* is built from. Its release happened ON the scrim (nothing captured the pointer
|
|
6619
|
+
* yet), and WebKit works out what a click hit by walking the pointerup target's LIVE
|
|
6620
|
+
* ancestors: take the node out here and that chain is rooted nowhere, so no `click`
|
|
6621
|
+
* fires at all, no `dblclick` follows, and the seam never resets. Chromium and Gecko
|
|
6622
|
+
* resolve the target before dispatch and do not care.
|
|
6623
|
+
*
|
|
6624
|
+
* So a retired scrim goes INERT immediately — `pointer-events: none`, which is what
|
|
6625
|
+
* actually matters, since a full-page overlay left hit-testable is a page dead to the
|
|
6626
|
+
* pointer — and leaves the document a moment later. Not on the next task: a first
|
|
6627
|
+
* measurement removed it there and it still raced the click under a loaded machine
|
|
6628
|
+
* (the trace is in `e2e/tests/layout.spec.ts`). The reference is dropped either way,
|
|
6629
|
+
* so the next press builds its own; an inert one on its way out costs nothing.
|
|
6630
|
+
*
|
|
6631
|
+
* Teardown never retires: `disconnectedCallback` takes the live one out at once.
|
|
6632
|
+
*/
|
|
6633
|
+
_removeScrim(retire = false) {
|
|
6634
|
+
const scrim = this._scrim;
|
|
6635
|
+
this._scrim = null;
|
|
6636
|
+
if (!scrim) return;
|
|
6637
|
+
if (!retire) {
|
|
6638
|
+
scrim.remove();
|
|
6639
|
+
return;
|
|
6640
|
+
}
|
|
6641
|
+
scrim.style.pointerEvents = "none";
|
|
6642
|
+
setTimeout(() => scrim.remove(), SCRIM_RETIRE_MS);
|
|
6643
|
+
}
|
|
6644
|
+
_onDoubleClick = () => {
|
|
6645
|
+
if (this.disabled || this.stacked) return;
|
|
6646
|
+
this.reset();
|
|
6647
|
+
};
|
|
6648
|
+
// ─── Keyboard ─────────────────────────────────────────────────────────
|
|
6649
|
+
_onKeydown = (event) => {
|
|
6650
|
+
if (this._dragging && event.key === "Escape") {
|
|
6651
|
+
event.preventDefault();
|
|
6652
|
+
this._endDrag("pointer", this._dragStartPercent);
|
|
6653
|
+
return;
|
|
6654
|
+
}
|
|
6655
|
+
if (this.disabled || this.stacked || event.target !== this._handle) return;
|
|
6656
|
+
if (event.key === "Enter") {
|
|
6657
|
+
event.preventDefault();
|
|
6658
|
+
this._source = "keyboard";
|
|
6659
|
+
try {
|
|
6660
|
+
this.toggleCollapse();
|
|
6661
|
+
} finally {
|
|
6662
|
+
this._source = "api";
|
|
6663
|
+
}
|
|
6664
|
+
return;
|
|
6665
|
+
}
|
|
6666
|
+
const delta = keyDelta(
|
|
6667
|
+
event.key,
|
|
6668
|
+
event.shiftKey,
|
|
6669
|
+
this._rtl(),
|
|
6670
|
+
this.orientation === "vertical",
|
|
6671
|
+
this.primary === "end"
|
|
6672
|
+
);
|
|
6673
|
+
if (delta === null) return;
|
|
6674
|
+
event.preventDefault();
|
|
6675
|
+
this._keying = true;
|
|
6676
|
+
this._position = toPercent(String(this._position + delta), this._position);
|
|
6677
|
+
this._setLive(this._position);
|
|
6678
|
+
};
|
|
6679
|
+
/**
|
|
6680
|
+
* The keyup commits, not the keydown: a held arrow repeats, and one release should
|
|
6681
|
+
* write one attribute and send one event, not thirty.
|
|
6682
|
+
*/
|
|
6683
|
+
_onKeyup = (event) => {
|
|
6684
|
+
if (!this._keying || event.target !== this._handle) return;
|
|
6685
|
+
if (keyDelta(event.key, event.shiftKey, this._rtl(), this.orientation === "vertical") === null) return;
|
|
6686
|
+
this._keying = false;
|
|
6687
|
+
this._commit("keyboard");
|
|
6688
|
+
};
|
|
6689
|
+
/**
|
|
6690
|
+
* The other end of a key step. If focus leaves the seam while the key is still down —
|
|
6691
|
+
* alt-tab, a click into a pane, a host moving focus — the keyup never arrives, and
|
|
6692
|
+
* the pane would stay where the keydown put it while the attribute and
|
|
6693
|
+
* `aria-valuenow` kept the old number for good. What is rendered is what commits.
|
|
6694
|
+
*/
|
|
6695
|
+
_onFocusOut = () => {
|
|
6696
|
+
if (!this._keying) return;
|
|
6697
|
+
this._keying = false;
|
|
6698
|
+
this._commit("keyboard");
|
|
6699
|
+
};
|
|
6700
|
+
// ─── The breakpoint ───────────────────────────────────────────────────
|
|
6701
|
+
/**
|
|
6702
|
+
* The stacking query, from the `breakpoint` attribute: a length (`48rem`, the
|
|
6703
|
+
* default, or `640px`), or `none` for a split that stays two panes at any width —
|
|
6704
|
+
* which a documentation frame needs and a host with its own breakpoint wants.
|
|
6705
|
+
*/
|
|
6706
|
+
_watchBreakpoint() {
|
|
6707
|
+
this._media?.removeEventListener("change", this._onMediaChange);
|
|
6708
|
+
this._media = null;
|
|
6709
|
+
const attr = (this.getAttribute("breakpoint") ?? "").trim();
|
|
6710
|
+
if (attr === "none") {
|
|
6711
|
+
this._applyStacked(false);
|
|
6712
|
+
return;
|
|
6713
|
+
}
|
|
6714
|
+
if (typeof matchMedia !== "function") return;
|
|
6715
|
+
const query = attr ? `(max-width: ${attr})` : STACK_QUERY;
|
|
6716
|
+
this._media = matchMedia(query);
|
|
6717
|
+
this._media.addEventListener("change", this._onMediaChange);
|
|
6718
|
+
this._applyStacked(this._media.matches);
|
|
6719
|
+
}
|
|
6720
|
+
_onMediaChange = (e) => this._applyStacked(e.matches);
|
|
6721
|
+
/**
|
|
6722
|
+
* Entering the stacked state shows the START pane: the chat, never a preview of
|
|
6723
|
+
* nothing — the same judgement that makes the sidebar's drawer enter closed. Unless
|
|
6724
|
+
* the markup already named one, which is an answer to the same question and a better
|
|
6725
|
+
* one. Leaving it restores both panes, and with them the seam and its tab stop.
|
|
6726
|
+
*
|
|
6727
|
+
* `was` is the ATTRIBUTE, not the `stacked` getter: the getter also counts
|
|
6728
|
+
* `.aparte-split--only-*`, the CSS route a host takes when it owns its breakpoints
|
|
6729
|
+
* and sets `breakpoint="none"`. Read through the getter, that mount looks like a
|
|
6730
|
+
* split leaving a stacked state it never entered — and the `pane` the author wrote
|
|
6731
|
+
* is deleted on the way in.
|
|
6732
|
+
*/
|
|
6733
|
+
_applyStacked(stacked) {
|
|
6734
|
+
const was = this.hasAttribute("data-stacked");
|
|
6735
|
+
if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
|
|
6736
|
+
this.toggleAttribute("data-stacked", stacked);
|
|
6737
|
+
if (stacked && !was) {
|
|
6738
|
+
if (!this.hasAttribute("pane")) this.showPane("start");
|
|
6739
|
+
} else if (!stacked && was) {
|
|
6740
|
+
this.removeAttribute("pane");
|
|
6741
|
+
}
|
|
6742
|
+
this._lastPane = this.pane;
|
|
6743
|
+
this._stampHandle();
|
|
6744
|
+
if (stacked !== was) this._measureBounds();
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* A `[data-aparte-split-pane]` anywhere on the page switches a split's pane, so a
|
|
6748
|
+
* two-button toggle in a header needs no script.
|
|
6749
|
+
*
|
|
6750
|
+
* The value is `start` or `end` — the pane to show. Any other non-empty value names
|
|
6751
|
+
* a split's `id` and TOGGLES that one; empty toggles the nearest split, else the
|
|
6752
|
+
* first on the page. Same shape as `[data-aparte-sidebar-toggle]`.
|
|
6753
|
+
*/
|
|
6754
|
+
_onDocumentClick = (e) => {
|
|
6755
|
+
const control = e.target?.closest?.("[data-aparte-split-pane]");
|
|
6756
|
+
if (!control) return;
|
|
6757
|
+
const value = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
|
|
6758
|
+
const which = value === "start" || value === "end" ? value : null;
|
|
6759
|
+
const named = which ? "" : value;
|
|
6760
|
+
if (named) {
|
|
6761
|
+
if (named !== this.id) return;
|
|
6762
|
+
} else if ((control.closest("aparte-split") ?? document.querySelector("aparte-split")) !== this) {
|
|
6763
|
+
return;
|
|
6764
|
+
}
|
|
6765
|
+
this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
|
|
6766
|
+
};
|
|
6767
|
+
// ─── Locale ───────────────────────────────────────────────────────────
|
|
6768
|
+
_onConfigChange = () => this._relabel();
|
|
6769
|
+
/**
|
|
6770
|
+
* The seam's accessible name. A host that wrote its own `aria-label` — or an
|
|
6771
|
+
* `aria-labelledby` pointing at a heading — keeps it: `data-own-label` marks the
|
|
6772
|
+
* one we set, so a language switch replaces ours and never theirs.
|
|
6773
|
+
*/
|
|
6774
|
+
_relabel() {
|
|
6775
|
+
const handle = this._handle;
|
|
6776
|
+
if (!handle) return;
|
|
6777
|
+
if (handle.hasAttribute("aria-labelledby")) return;
|
|
6778
|
+
if (handle.hasAttribute("aria-label") && handle.dataset["ownLabel"] !== "locale") return;
|
|
6779
|
+
const locale = resolveConfig(this).getLocale();
|
|
6780
|
+
handle.setAttribute("aria-label", this.getAttribute("label") ?? locale.splitHandleLabel ?? "Resize the panes");
|
|
6781
|
+
handle.dataset["ownLabel"] = "locale";
|
|
6782
|
+
}
|
|
6783
|
+
}
|
|
6784
|
+
if (!customElements.get("aparte-split")) {
|
|
6785
|
+
customElements.define("aparte-split", AparteSplit);
|
|
6786
|
+
}
|
|
6787
|
+
const APARTE_ELEMENTS = [
|
|
6788
|
+
// Chat shell + transcript
|
|
6789
|
+
["aparte-chat", AparteChat],
|
|
6790
|
+
["aparte-chat-viewport", AparteChatViewport],
|
|
6791
|
+
["aparte-chat-bubble", AparteChatBubble],
|
|
6792
|
+
["aparte-chat-status", AparteChatStatus],
|
|
6793
|
+
// Composer and its primitives
|
|
6794
|
+
["aparte-composer", AparteComposer],
|
|
6795
|
+
["aparte-composer-input", AparteComposerInput],
|
|
6796
|
+
["aparte-composer-send", AparteComposerSend],
|
|
6797
|
+
["aparte-composer-cancel", AparteComposerCancel],
|
|
6798
|
+
["aparte-composer-attachments", AparteComposerAttachments],
|
|
6799
|
+
["aparte-composer-add-attachment", AparteComposerAddAttachment],
|
|
6800
|
+
["aparte-composer-action", AparteComposerAction],
|
|
6801
|
+
["aparte-composer-toolbar", AparteComposerToolbar],
|
|
6802
|
+
// Standalone components
|
|
6803
|
+
["aparte-conversation-list", AparteConversationList],
|
|
6804
|
+
["aparte-elicitation", AparteElicitation],
|
|
6805
|
+
["aparte-scroll-rail", AparteScrollRail],
|
|
6806
|
+
["aparte-sidebar", AparteSidebar],
|
|
6807
|
+
["aparte-split", AparteSplit],
|
|
6808
|
+
["aparte-suggestions", AparteSuggestions],
|
|
6809
|
+
["aparte-context", AparteContext],
|
|
6810
|
+
// Primitives
|
|
6811
|
+
["aparte-select", AparteSelect],
|
|
6812
|
+
["aparte-option", AparteOption],
|
|
6813
|
+
["aparte-optgroup", AparteOptgroup],
|
|
6814
|
+
["aparte-progress-spinner", AparteProgressSpinner],
|
|
6815
|
+
["aparte-icon", AparteIcon]
|
|
6816
|
+
];
|
|
5019
6817
|
function registerAllComponents() {
|
|
5020
|
-
const
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
console.warn("[Aparte] Some components may not be registered. Ensure all component files are imported.");
|
|
6818
|
+
const missing = APARTE_ELEMENTS.filter(([tag]) => !customElements.get(tag)).map(([tag]) => tag);
|
|
6819
|
+
if (missing.length > 0) {
|
|
6820
|
+
console.warn(
|
|
6821
|
+
`[aparte] ${missing.length} of ${APARTE_ELEMENTS.length} elements are not registered: ${missing.join(", ")}. Import '@aparte/core' at module scope (not behind a lazy import()), and check that your bundler is honouring the package's sideEffects field.`
|
|
6822
|
+
);
|
|
5026
6823
|
}
|
|
5027
6824
|
}
|
|
6825
|
+
if (typeof document !== "undefined") installDialogTriggersOnce();
|
|
5028
6826
|
export {
|
|
5029
6827
|
A as APARTE_CONFIG_CHANGE,
|
|
5030
6828
|
a as APARTE_CONVERSATION_SCHEMA_VERSION,
|
|
5031
6829
|
b as APARTE_DEFAULT_BUBBLE_ACTIONS,
|
|
5032
6830
|
c as APARTE_DEFAULT_HOST_HANDLERS,
|
|
5033
6831
|
d as APARTE_DEFAULT_ICON_FALLBACKS,
|
|
5034
|
-
|
|
6832
|
+
APARTE_DEFAULT_LOCALE,
|
|
5035
6833
|
f as APARTE_DEFAULT_UI_EVENTS,
|
|
5036
6834
|
g as APARTE_HOST_ATTR,
|
|
5037
6835
|
h as AparteBackendTransport,
|
|
@@ -5064,7 +6862,10 @@ export {
|
|
|
5064
6862
|
AparteOptgroup,
|
|
5065
6863
|
AparteOption,
|
|
5066
6864
|
AparteProgressSpinner,
|
|
6865
|
+
AparteScrollRail,
|
|
5067
6866
|
AparteSelect,
|
|
6867
|
+
AparteSidebar,
|
|
6868
|
+
AparteSplit,
|
|
5068
6869
|
s as AparteStreamParser,
|
|
5069
6870
|
AparteSuggestions,
|
|
5070
6871
|
t as aparteGlobalConfig,
|
|
@@ -5075,38 +6876,39 @@ export {
|
|
|
5075
6876
|
y as collectRendererStyles,
|
|
5076
6877
|
z as contentToText,
|
|
5077
6878
|
B as contextConfig,
|
|
5078
|
-
|
|
5079
|
-
D as
|
|
6879
|
+
copyText,
|
|
6880
|
+
D as createAparteChatHandler,
|
|
6881
|
+
E as createStreamAdapter,
|
|
5080
6882
|
cssEscape,
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
H as detachConfig,
|
|
6883
|
+
G as declineDefaultRenderers,
|
|
6884
|
+
H as defaultSanitizer,
|
|
6885
|
+
I as detachConfig,
|
|
5085
6886
|
escapeAttr,
|
|
5086
6887
|
escapeHtml,
|
|
5087
|
-
|
|
5088
|
-
|
|
6888
|
+
L as filesToAttachments,
|
|
6889
|
+
M as getAllRenderers,
|
|
5089
6890
|
getSegmentRenderer,
|
|
5090
6891
|
installDefaultRenderersOnce,
|
|
6892
|
+
installDialogTriggersOnce,
|
|
5091
6893
|
isAwaitingReply,
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
6894
|
+
R as isFormatAdapter,
|
|
6895
|
+
S as isSafeUrl,
|
|
6896
|
+
T as isSegmentSettled,
|
|
6897
|
+
U as parseAparteEventStream,
|
|
6898
|
+
V as parseMarkdownToSegments,
|
|
5097
6899
|
populateBubbleFromMessage,
|
|
5098
|
-
|
|
6900
|
+
X as readableToAsyncIterable,
|
|
5099
6901
|
registerAllComponents,
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
6902
|
+
Y as registerDefaultRenderers,
|
|
6903
|
+
Z as registerSegmentRenderer,
|
|
6904
|
+
_ as requestUserInput,
|
|
5103
6905
|
resolveConfig,
|
|
5104
6906
|
revokeAttachmentUrls,
|
|
5105
6907
|
runWithConfig,
|
|
5106
|
-
|
|
5107
|
-
|
|
6908
|
+
a2 as segmentDuration,
|
|
6909
|
+
a3 as segmentTiming,
|
|
5108
6910
|
subscribeConfigChange,
|
|
5109
|
-
|
|
6911
|
+
a5 as unregisterSegmentRenderer,
|
|
5110
6912
|
uuid
|
|
5111
6913
|
};
|
|
5112
6914
|
//# sourceMappingURL=index.js.map
|