@aparte/core 0.16.8 → 0.16.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/aparte-client.d.ts +57 -2
- package/dist/client/aparte-client.d.ts.map +1 -1
- package/dist/client/lifecycle-events.d.ts +8 -1
- package/dist/client/lifecycle-events.d.ts.map +1 -1
- package/dist/client/stream-adapter.d.ts +8 -0
- package/dist/client/stream-adapter.d.ts.map +1 -1
- package/dist/components/bubble/aparte-chat-bubble.d.ts +6 -0
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
- package/dist/components/composer/aparte-composer-input.d.ts +1 -1
- package/dist/components/composer/aparte-composer.d.ts.map +1 -1
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +5 -0
- package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +91 -10
- package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
- package/dist/components/status/aparte-chat-status.d.ts +41 -3
- package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts +8 -1
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
- package/dist/conversations/conversation-manager.d.ts +29 -0
- package/dist/conversations/conversation-manager.d.ts.map +1 -1
- package/dist/conversations/index.d.ts +1 -1
- package/dist/conversations/index.d.ts.map +1 -1
- package/dist/custom-elements.json +10916 -10397
- package/dist/{dialog-triggers-D8wNICrT.js → dialog-triggers-cYGTlfZf.js} +128 -30
- package/dist/{dialog-triggers-D8wNICrT.js.map → dialog-triggers-cYGTlfZf.js.map} +1 -1
- package/dist/elicitation/panel.d.ts.map +1 -1
- package/dist/index.css +162 -40
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +447 -94
- package/dist/index.js.map +1 -1
- package/dist/index.node.d.ts +1 -1
- package/dist/index.node.d.ts.map +1 -1
- package/dist/index.node.js +1 -1
- package/dist/index.node.js.map +1 -1
- package/dist/interop/element-props.d.ts.map +1 -1
- package/dist/primitives/select/aparte-select.d.ts +25 -3
- package/dist/primitives/select/aparte-select.d.ts.map +1 -1
- package/dist/types/events.d.ts +4 -2
- package/dist/types/events.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-
|
|
2
|
-
import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-
|
|
1
|
+
import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-cYGTlfZf.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-cYGTlfZf.js";
|
|
3
3
|
function presenceOn(value) {
|
|
4
4
|
return value === "" ? true : Boolean(value);
|
|
5
5
|
}
|
|
@@ -207,11 +207,12 @@ class AparteSelect extends HTMLElement {
|
|
|
207
207
|
}
|
|
208
208
|
connectedCallback() {
|
|
209
209
|
this._value = this.getAttribute("value") || "";
|
|
210
|
-
|
|
210
|
+
const wantsOpen = this.hasAttribute("open") && !this._isOpen;
|
|
211
211
|
this._render();
|
|
212
212
|
this._applyDisabled();
|
|
213
213
|
this._setupEventListeners();
|
|
214
214
|
this._setupMutationObserver();
|
|
215
|
+
if (wantsOpen) this._openDropdown();
|
|
215
216
|
}
|
|
216
217
|
disconnectedCallback() {
|
|
217
218
|
this.removeEventListener("click", this._boundHandleOptionClick);
|
|
@@ -226,20 +227,18 @@ class AparteSelect extends HTMLElement {
|
|
|
226
227
|
this._updateTriggerLabel();
|
|
227
228
|
}
|
|
228
229
|
if (name === "open") {
|
|
229
|
-
|
|
230
|
-
if (this._isOpen) {
|
|
231
|
-
this.
|
|
232
|
-
this.
|
|
233
|
-
} else {
|
|
234
|
-
this._dropdown?.setAttribute("hidden", "");
|
|
230
|
+
const wanted = this.hasAttribute("open");
|
|
231
|
+
if (wanted !== this._isOpen) {
|
|
232
|
+
if (wanted) this._openDropdown();
|
|
233
|
+
else this._closeDropdown();
|
|
235
234
|
}
|
|
236
235
|
}
|
|
237
236
|
if (name === "placeholder") {
|
|
238
237
|
this._updateTriggerLabel();
|
|
239
|
-
this._updateNames();
|
|
240
238
|
}
|
|
241
239
|
if (name === "disabled") {
|
|
242
240
|
this._applyDisabled();
|
|
241
|
+
if (!this.hasAttribute("disabled") && this.hasAttribute("open")) this._openDropdown();
|
|
243
242
|
}
|
|
244
243
|
}
|
|
245
244
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -277,7 +276,7 @@ class AparteSelect extends HTMLElement {
|
|
|
277
276
|
const trigger = document.createElement("div");
|
|
278
277
|
trigger.className = "aparte-select-trigger";
|
|
279
278
|
trigger.setAttribute("tabindex", "0");
|
|
280
|
-
trigger.setAttribute("role", "combobox");
|
|
279
|
+
trigger.setAttribute("role", searchable ? "button" : "combobox");
|
|
281
280
|
trigger.setAttribute("aria-haspopup", "listbox");
|
|
282
281
|
trigger.setAttribute("aria-expanded", "false");
|
|
283
282
|
trigger.setAttribute("aria-label", this.getAttribute("aria-label") || placeholder);
|
|
@@ -297,12 +296,17 @@ class AparteSelect extends HTMLElement {
|
|
|
297
296
|
const dropdown = document.createElement("div");
|
|
298
297
|
dropdown.className = "aparte-select-dropdown";
|
|
299
298
|
dropdown.hidden = !this._isOpen;
|
|
299
|
+
let searchInput = null;
|
|
300
300
|
if (searchable) {
|
|
301
|
-
|
|
301
|
+
searchInput = document.createElement("input");
|
|
302
302
|
searchInput.type = "text";
|
|
303
303
|
searchInput.className = "aparte-select-search";
|
|
304
304
|
searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
|
|
305
305
|
searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
|
|
306
|
+
searchInput.setAttribute("role", "combobox");
|
|
307
|
+
searchInput.setAttribute("aria-haspopup", "listbox");
|
|
308
|
+
searchInput.setAttribute("aria-autocomplete", "list");
|
|
309
|
+
searchInput.setAttribute("aria-expanded", String(this._isOpen));
|
|
306
310
|
dropdown.appendChild(searchInput);
|
|
307
311
|
}
|
|
308
312
|
const optionsContainer = document.createElement("div");
|
|
@@ -311,6 +315,7 @@ class AparteSelect extends HTMLElement {
|
|
|
311
315
|
optionsContainer.setAttribute("aria-label", trigger.getAttribute("aria-label") ?? placeholder);
|
|
312
316
|
optionsContainer.id = this.id ? `${this.id}-listbox` : `aparte-listbox-${++AparteSelect._listboxSeq}`;
|
|
313
317
|
trigger.setAttribute("aria-controls", optionsContainer.id);
|
|
318
|
+
searchInput?.setAttribute("aria-controls", optionsContainer.id);
|
|
314
319
|
slottedChildren.forEach((child) => {
|
|
315
320
|
if (child.tagName === "APARTE-OPTION" || child.tagName === "APARTE-OPTGROUP") {
|
|
316
321
|
optionsContainer.appendChild(child);
|
|
@@ -459,6 +464,20 @@ class AparteSelect extends HTMLElement {
|
|
|
459
464
|
const base = this._activeIndex < 0 ? delta > 0 ? -1 : 0 : this._activeIndex;
|
|
460
465
|
this._setActive(base + delta);
|
|
461
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* The element that holds DOM focus while the list is being driven, and therefore
|
|
469
|
+
* the one that must carry the combobox state a reader follows: the filter field
|
|
470
|
+
* when the select is `searchable` (it is focused on open), the trigger otherwise.
|
|
471
|
+
*/
|
|
472
|
+
_activeHost() {
|
|
473
|
+
return this._searchInput ?? this._trigger;
|
|
474
|
+
}
|
|
475
|
+
/** `aria-expanded` belongs to the combobox — write it wherever the combobox is. */
|
|
476
|
+
_setExpanded(open) {
|
|
477
|
+
const flag = String(open);
|
|
478
|
+
this._trigger?.setAttribute("aria-expanded", flag);
|
|
479
|
+
this._searchInput?.setAttribute("aria-expanded", flag);
|
|
480
|
+
}
|
|
462
481
|
/** Highlight the option at `index` (clamped) and point aria-activedescendant at it. */
|
|
463
482
|
_setActive(index) {
|
|
464
483
|
const all = this.querySelectorAll("aparte-option");
|
|
@@ -466,7 +485,7 @@ class AparteSelect extends HTMLElement {
|
|
|
466
485
|
const opts = this._visibleOptions();
|
|
467
486
|
if (opts.length === 0) {
|
|
468
487
|
this._activeIndex = -1;
|
|
469
|
-
this.
|
|
488
|
+
this._activeHost()?.removeAttribute("aria-activedescendant");
|
|
470
489
|
return;
|
|
471
490
|
}
|
|
472
491
|
const clamped = Math.max(0, Math.min(index, opts.length - 1));
|
|
@@ -474,13 +493,13 @@ class AparteSelect extends HTMLElement {
|
|
|
474
493
|
const active = opts[clamped];
|
|
475
494
|
if (!active.id) active.id = `aparte-option-${++AparteSelect._optIdSeq}`;
|
|
476
495
|
active.setAttribute("data-active", "");
|
|
477
|
-
this.
|
|
496
|
+
this._activeHost()?.setAttribute("aria-activedescendant", active.id);
|
|
478
497
|
active.scrollIntoView?.({ block: "nearest" });
|
|
479
498
|
}
|
|
480
499
|
/** Clear the keyboard highlight (on close). */
|
|
481
500
|
_clearActive() {
|
|
482
501
|
this._activeIndex = -1;
|
|
483
|
-
this.
|
|
502
|
+
this._activeHost()?.removeAttribute("aria-activedescendant");
|
|
484
503
|
this.querySelectorAll("aparte-option").forEach((o) => o.removeAttribute("data-active"));
|
|
485
504
|
}
|
|
486
505
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -494,10 +513,11 @@ class AparteSelect extends HTMLElement {
|
|
|
494
513
|
}
|
|
495
514
|
}
|
|
496
515
|
_openDropdown() {
|
|
516
|
+
if (this._isOpen) return;
|
|
497
517
|
if (this.hasAttribute("disabled")) return;
|
|
498
518
|
this._isOpen = true;
|
|
499
519
|
this._dropdown?.removeAttribute("hidden");
|
|
500
|
-
this.
|
|
520
|
+
this._setExpanded(true);
|
|
501
521
|
this.setAttribute("open", "");
|
|
502
522
|
this._updatePosition();
|
|
503
523
|
this._searchInput?.focus();
|
|
@@ -510,7 +530,7 @@ class AparteSelect extends HTMLElement {
|
|
|
510
530
|
this._isOpen = false;
|
|
511
531
|
this._clearActive();
|
|
512
532
|
this._dropdown?.setAttribute("hidden", "");
|
|
513
|
-
this.
|
|
533
|
+
this._setExpanded(false);
|
|
514
534
|
this.removeAttribute("open");
|
|
515
535
|
this.removeAttribute("position");
|
|
516
536
|
if (this._dropdown) {
|
|
@@ -556,15 +576,28 @@ class AparteSelect extends HTMLElement {
|
|
|
556
576
|
}
|
|
557
577
|
/**
|
|
558
578
|
* The combobox and its listbox are named by the host's `aria-label`, else by the
|
|
559
|
-
* placeholder — the same rule the first render applies, re-applied
|
|
560
|
-
* placeholder changes. Written only when different: the mutation
|
|
561
|
-
* this subtree.
|
|
579
|
+
* placeholder — the same rule the first render applies, re-applied whenever the
|
|
580
|
+
* placeholder or the selection changes. Written only when different: the mutation
|
|
581
|
+
* observer watches this subtree.
|
|
582
|
+
*
|
|
583
|
+
* A `combobox` is NAMED by that attribute and VALUED by its visible label span, so
|
|
584
|
+
* the name is the control and nothing else. A `button` — the shape a `searchable`
|
|
585
|
+
* trigger takes, since the filter field is then the combobox — is named by its
|
|
586
|
+
* CONTENT, which this very attribute overrides: the searchable trigger announced
|
|
587
|
+
* "Pick a model, button" and never said which model was selected. So the button's
|
|
588
|
+
* name carries both halves, and drops the second when it would only repeat the
|
|
589
|
+
* first (nothing selected: the value IS the placeholder).
|
|
562
590
|
*/
|
|
563
591
|
_updateNames() {
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
592
|
+
const placeholder = this.getAttribute("placeholder") || "Select...";
|
|
593
|
+
const name = this.getAttribute("aria-label") || placeholder;
|
|
594
|
+
const list = this.querySelector(".aparte-select-options");
|
|
595
|
+
if (list && list.getAttribute("aria-label") !== name) list.setAttribute("aria-label", name);
|
|
596
|
+
const trigger = this._trigger;
|
|
597
|
+
if (!trigger) return;
|
|
598
|
+
const value = this._getSelectedLabel() || placeholder;
|
|
599
|
+
const wanted = trigger.getAttribute("role") === "button" && value !== name ? `${name}: ${value}` : name;
|
|
600
|
+
if (trigger.getAttribute("aria-label") !== wanted) trigger.setAttribute("aria-label", wanted);
|
|
568
601
|
}
|
|
569
602
|
/**
|
|
570
603
|
* `disabled` takes the trigger out of the tab order and says so — opacity alone
|
|
@@ -601,6 +634,7 @@ class AparteSelect extends HTMLElement {
|
|
|
601
634
|
}));
|
|
602
635
|
}
|
|
603
636
|
}
|
|
637
|
+
this._updateNames();
|
|
604
638
|
const options = this.querySelectorAll("aparte-option");
|
|
605
639
|
options.forEach((opt) => {
|
|
606
640
|
const isSelected = opt.getAttribute("value") === this._value;
|
|
@@ -1089,6 +1123,12 @@ class AparteChatBubble extends HTMLElement {
|
|
|
1089
1123
|
_editing = false;
|
|
1090
1124
|
/** The live inline editor (the composer's contenteditable primitive), present only while `_editing`. */
|
|
1091
1125
|
_editInput = null;
|
|
1126
|
+
/**
|
|
1127
|
+
* The `data-action` of the button the editor was opened from, so leaving it can hand
|
|
1128
|
+
* the keyboard back. The STRING, not the node: entering and leaving edit mode rewrite
|
|
1129
|
+
* the action bar's `innerHTML`, so the opener's identity is destroyed twice.
|
|
1130
|
+
*/
|
|
1131
|
+
_editReturnAction = null;
|
|
1092
1132
|
static get observedAttributes() {
|
|
1093
1133
|
return ["data-role", "content", "timestamp", "message-id", "streaming", "name"];
|
|
1094
1134
|
}
|
|
@@ -2126,6 +2166,8 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2126
2166
|
*/
|
|
2127
2167
|
_enterEditMode() {
|
|
2128
2168
|
if (this._editing || !this._contentEl) return;
|
|
2169
|
+
const opener = document.activeElement;
|
|
2170
|
+
this._editReturnAction = opener instanceof HTMLElement && this.contains(opener) ? opener.getAttribute("data-action") ?? "edit" : null;
|
|
2129
2171
|
this._editing = true;
|
|
2130
2172
|
this.querySelector(".aparte-message")?.setAttribute("data-editing", "");
|
|
2131
2173
|
const input = document.createElement("aparte-composer-input");
|
|
@@ -2159,6 +2201,16 @@ class AparteChatBubble extends HTMLElement {
|
|
|
2159
2201
|
this.querySelector(".aparte-message")?.removeAttribute("data-editing");
|
|
2160
2202
|
this._editing = false;
|
|
2161
2203
|
this._updateActionBar();
|
|
2204
|
+
const returnTo = this._editReturnAction;
|
|
2205
|
+
this._editReturnAction = null;
|
|
2206
|
+
if (returnTo && this._actionBarEl) {
|
|
2207
|
+
const back = Array.from(this._actionBarEl.querySelectorAll("[data-action]")).find((btn) => btn.getAttribute("data-action") === returnTo);
|
|
2208
|
+
const target = back && !back.disabled ? back : this._actionBarEl.querySelector("button:not([disabled])");
|
|
2209
|
+
if (target) {
|
|
2210
|
+
for (const button of this._rovingButtons()) button.tabIndex = button === target ? 0 : -1;
|
|
2211
|
+
target.focus();
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2162
2214
|
if (save && newContent && newContent !== original) {
|
|
2163
2215
|
const messageId = this.getAttribute("message-id");
|
|
2164
2216
|
if (messageId) {
|
|
@@ -2306,27 +2358,63 @@ class AparteChatStatus extends HTMLElement {
|
|
|
2306
2358
|
<span class="aparte-dot"></span>
|
|
2307
2359
|
</div>
|
|
2308
2360
|
<span class="aparte-status-text"></span>
|
|
2361
|
+
<span class="aparte-sr-only aparte-status-sr"></span>
|
|
2309
2362
|
</div>
|
|
2310
2363
|
</div>
|
|
2311
2364
|
</div>
|
|
2312
2365
|
`;
|
|
2313
2366
|
this.querySelector(".aparte-status-container")?.setAttribute("aria-label", text);
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2367
|
+
this._writeText(this.getAttribute("text"));
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* The one writer for both text nodes, so exactly ONE of them is ever populated.
|
|
2371
|
+
*
|
|
2372
|
+
* The container is a polite live region, and a live region announces its CONTENT.
|
|
2373
|
+
* The dots-only default had none — an `aria-hidden` dot and an empty span — so the
|
|
2374
|
+
* whole state rode on `aria-label`, which names the region rather than reporting it,
|
|
2375
|
+
* and nothing was announced. The screen-reader span carries the fallback word in that
|
|
2376
|
+
* case; when `text` is set the visible span already carries the same string, and
|
|
2377
|
+
* populating both would have the region read it twice.
|
|
2378
|
+
*/
|
|
2379
|
+
_writeText(text) {
|
|
2380
|
+
const line = text || null;
|
|
2381
|
+
const textEl = this.querySelector(".aparte-status-text");
|
|
2382
|
+
if (textEl) textEl.textContent = line ?? "";
|
|
2383
|
+
this._syncLiveWord();
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* The fallback word enters and leaves the live region WITH the element's visibility.
|
|
2387
|
+
*
|
|
2388
|
+
* Writing it at render time put it in the region while the host was still
|
|
2389
|
+
* `display: none` — every wrapper mounts `<aparte-chat-status>` once and flips
|
|
2390
|
+
* `visible` — so by the time the region was exposed its content had not changed, and
|
|
2391
|
+
* on the second and third turn there was not even a reveal to notice: the text was
|
|
2392
|
+
* byte-identical to what was already sitting there. Revealing a region that already
|
|
2393
|
+
* holds its text is the path assistive tech is documented not to announce reliably;
|
|
2394
|
+
* mutating one that is already exposed is the path that works. So the word is written
|
|
2395
|
+
* when `visible` arrives and cleared when it leaves, which makes every turn a real
|
|
2396
|
+
* change.
|
|
2397
|
+
*
|
|
2398
|
+
* With `text` set the visible span is the region's content and the screen-reader span
|
|
2399
|
+
* stays empty — one copy, read once.
|
|
2400
|
+
*/
|
|
2401
|
+
_syncLiveWord() {
|
|
2402
|
+
const srEl = this.querySelector(".aparte-status-sr");
|
|
2403
|
+
if (!srEl) return;
|
|
2404
|
+
const dotsOnly = !this.getAttribute("text");
|
|
2405
|
+
srEl.textContent = dotsOnly && this.hasAttribute("visible") ? "Typing" : "";
|
|
2318
2406
|
}
|
|
2319
2407
|
_updateVisibility(visible) {
|
|
2320
2408
|
const container = this.querySelector(".aparte-status-container");
|
|
2321
2409
|
if (container) {
|
|
2322
2410
|
container.setAttribute("data-visible", String(visible));
|
|
2323
2411
|
}
|
|
2412
|
+
this._syncLiveWord();
|
|
2324
2413
|
}
|
|
2325
2414
|
_updateText(text) {
|
|
2326
|
-
const textEl = this.querySelector(".aparte-status-text");
|
|
2327
2415
|
const container = this.querySelector(".aparte-status-container");
|
|
2328
2416
|
if (!container) return;
|
|
2329
|
-
|
|
2417
|
+
this._writeText(text);
|
|
2330
2418
|
container.setAttribute("aria-label", text || "Typing");
|
|
2331
2419
|
}
|
|
2332
2420
|
}
|
|
@@ -3382,11 +3470,18 @@ class AparteChatViewport extends HTMLElement {
|
|
|
3382
3470
|
});
|
|
3383
3471
|
}
|
|
3384
3472
|
}
|
|
3385
|
-
/**
|
|
3473
|
+
/**
|
|
3474
|
+
* The overlay shell's element children that are the floating bottom stack: not this
|
|
3475
|
+
* viewport, and not the scroll rail, which floats over the transcript by design —
|
|
3476
|
+
* shell.css names it the same way (`:not(aparte-chat-viewport, aparte-scroll-rail)`).
|
|
3477
|
+
* Counted, a rail centred on the transcript set the inset to the distance from its
|
|
3478
|
+
* own top edge (398px on the vanilla example) and held it there while the composer
|
|
3479
|
+
* grew under a draft.
|
|
3480
|
+
*/
|
|
3386
3481
|
_overlayStack() {
|
|
3387
3482
|
if (!this._overlayRoot) return [];
|
|
3388
3483
|
return Array.from(this._overlayRoot.children).filter(
|
|
3389
|
-
(el2) => el2 instanceof HTMLElement && el2 !== this
|
|
3484
|
+
(el2) => el2 instanceof HTMLElement && el2 !== this && el2.tagName.toLowerCase() !== "aparte-scroll-rail"
|
|
3390
3485
|
);
|
|
3391
3486
|
}
|
|
3392
3487
|
/**
|
|
@@ -4012,6 +4107,8 @@ class AparteComposer extends HTMLElement {
|
|
|
4012
4107
|
onEvict?.();
|
|
4013
4108
|
}
|
|
4014
4109
|
_teardownPanel() {
|
|
4110
|
+
const active = document.activeElement;
|
|
4111
|
+
const hadFocus = active instanceof Node && this.contains(active);
|
|
4015
4112
|
const existing = this.querySelector("[data-aparte-panel]");
|
|
4016
4113
|
if (existing) existing.remove();
|
|
4017
4114
|
this.removeAttribute("data-panel-active");
|
|
@@ -4023,7 +4120,7 @@ class AparteComposer extends HTMLElement {
|
|
|
4023
4120
|
this._panelOnEvict = null;
|
|
4024
4121
|
this._panelToken = null;
|
|
4025
4122
|
this._emit("panel-change", { active: false, submitEnabled: false, mode: "submit" });
|
|
4026
|
-
this.focus();
|
|
4123
|
+
if (hadFocus) this.focus();
|
|
4027
4124
|
}
|
|
4028
4125
|
/**
|
|
4029
4126
|
* Update the send button's state while a panel is active.
|
|
@@ -4085,7 +4182,14 @@ class AparteComposer extends HTMLElement {
|
|
|
4085
4182
|
const detail = {
|
|
4086
4183
|
content: value,
|
|
4087
4184
|
timestamp: Date.now(),
|
|
4088
|
-
|
|
4185
|
+
/*
|
|
4186
|
+
* `_ownTargetId()`, not the bare attribute — the same resolution `cancel()`
|
|
4187
|
+
* uses, and for the same reason. The wrappers all set `target`, so this is
|
|
4188
|
+
* byte-identical there; in RAW core nothing writes it, so every send carried
|
|
4189
|
+
* `targetId: undefined` and a page with two hand-written chats delivered the
|
|
4190
|
+
* reply to whichever one the host resolved first.
|
|
4191
|
+
*/
|
|
4192
|
+
targetId: this._ownTargetId(),
|
|
4089
4193
|
files: this._attachments.length > 0 ? [...this._attachments] : void 0
|
|
4090
4194
|
};
|
|
4091
4195
|
this.dispatchEvent(new CustomEvent("aparte-send", {
|
|
@@ -4732,20 +4836,22 @@ class AparteComposerAttachments extends HTMLElement {
|
|
|
4732
4836
|
});
|
|
4733
4837
|
});
|
|
4734
4838
|
if (!resolveConfig(this).getHostHandlers().attachmentPreview) return;
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
tile.
|
|
4839
|
+
const images = files.filter((f2) => f2.type.startsWith("image/"));
|
|
4840
|
+
this.querySelectorAll(".aparte-thumb--image").forEach((tile, i2) => {
|
|
4841
|
+
const img = tile.querySelector(".aparte-thumbnail__image");
|
|
4842
|
+
if (!img) return;
|
|
4843
|
+
img.setAttribute("role", "button");
|
|
4844
|
+
img.setAttribute("tabindex", "0");
|
|
4845
|
+
img.setAttribute("aria-label", images[i2]?.name ?? "");
|
|
4738
4846
|
const open = () => {
|
|
4739
|
-
const img = tile.querySelector(".aparte-thumbnail__image");
|
|
4740
|
-
if (!img) return;
|
|
4741
4847
|
this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
|
|
4742
4848
|
bubbles: true,
|
|
4743
4849
|
composed: true,
|
|
4744
|
-
detail: { url: img.src, name:
|
|
4850
|
+
detail: { url: img.src, name: images[i2]?.name ?? "" }
|
|
4745
4851
|
}));
|
|
4746
4852
|
};
|
|
4747
|
-
|
|
4748
|
-
|
|
4853
|
+
img.addEventListener("click", open);
|
|
4854
|
+
img.addEventListener("keydown", (e) => {
|
|
4749
4855
|
const key = e.key;
|
|
4750
4856
|
if (key !== "Enter" && key !== " ") return;
|
|
4751
4857
|
e.preventDefault();
|
|
@@ -5375,7 +5481,10 @@ class AparteConversationList extends HTMLElement {
|
|
|
5375
5481
|
input.dataset["renameId"] = conv.id;
|
|
5376
5482
|
select.replaceWith(input);
|
|
5377
5483
|
this._renaming = { id: conv.id, input, done: false };
|
|
5378
|
-
input.addEventListener("blur", () =>
|
|
5484
|
+
input.addEventListener("blur", (e) => {
|
|
5485
|
+
const next = e.relatedTarget;
|
|
5486
|
+
this._finishRename(true, !next || this.contains(next));
|
|
5487
|
+
});
|
|
5379
5488
|
input.focus();
|
|
5380
5489
|
input.select();
|
|
5381
5490
|
}
|
|
@@ -5384,23 +5493,28 @@ class AparteConversationList extends HTMLElement {
|
|
|
5384
5493
|
* the blur a removal causes cannot commit a second time. The row is re-rendered
|
|
5385
5494
|
* from the array either way: the list shows data, and the host's re-assignment is
|
|
5386
5495
|
* what makes the new title appear.
|
|
5496
|
+
*
|
|
5497
|
+
* `restoreFocus` puts the keyboard back on the row: every exit destroys the field
|
|
5498
|
+
* the reader was in, so without it the focus lands on `<body>` and the next Tab
|
|
5499
|
+
* restarts at the top of the page. The caller decides — the blur path says no when
|
|
5500
|
+
* the focus is already heading somewhere live outside this list.
|
|
5387
5501
|
*/
|
|
5388
|
-
_finishRename(commit,
|
|
5502
|
+
_finishRename(commit, restoreFocus) {
|
|
5389
5503
|
const renaming = this._renaming;
|
|
5390
5504
|
if (!renaming || renaming.done) return;
|
|
5391
5505
|
renaming.done = true;
|
|
5392
5506
|
const conv = this._conversations.find((c2) => c2.id === renaming.id);
|
|
5393
5507
|
const title = renaming.input.value.trim();
|
|
5394
5508
|
this._render();
|
|
5395
|
-
if (viaKeyboard) {
|
|
5396
|
-
this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
|
|
5397
|
-
}
|
|
5398
5509
|
if (commit && conv && title && title !== conv.title) {
|
|
5399
5510
|
this.dispatchEvent(new CustomEvent(
|
|
5400
5511
|
"aparte-rename-conversation",
|
|
5401
5512
|
{ detail: { id: conv.id, title }, bubbles: true, composed: true }
|
|
5402
5513
|
));
|
|
5403
5514
|
}
|
|
5515
|
+
if (restoreFocus) {
|
|
5516
|
+
this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
|
|
5517
|
+
}
|
|
5404
5518
|
}
|
|
5405
5519
|
// ─── Events ───────────────────────────────────────────────────────────
|
|
5406
5520
|
_onClick = (e) => {
|
|
@@ -5775,18 +5889,40 @@ if (!customElements.get("aparte-context")) {
|
|
|
5775
5889
|
customElements.define("aparte-context", AparteContext);
|
|
5776
5890
|
}
|
|
5777
5891
|
const EXCERPT_LENGTH = 60;
|
|
5892
|
+
const MIN_PITCH = 6;
|
|
5893
|
+
const SETTLE_MS = 150;
|
|
5894
|
+
const MAX_ALIGN_PASSES = 4;
|
|
5895
|
+
const ALIGN_TOLERANCE = 1;
|
|
5778
5896
|
class AparteScrollRail extends HTMLElement {
|
|
5779
5897
|
static get observedAttributes() {
|
|
5780
5898
|
return ["target", "every"];
|
|
5781
5899
|
}
|
|
5782
5900
|
_host = null;
|
|
5783
5901
|
_mutations = null;
|
|
5902
|
+
_resizes = null;
|
|
5784
5903
|
_intersections = null;
|
|
5904
|
+
/** The bubbles the intersection observer watches — compared by identity, so a re-rendered bubble is re-observed. */
|
|
5905
|
+
_observedBubbles = [];
|
|
5906
|
+
/** The scroll surface the observer is rooted on, and whose bottom edge is read. */
|
|
5907
|
+
_observedSurface = null;
|
|
5785
5908
|
_rebuildQueued = false;
|
|
5909
|
+
_pickQueued = false;
|
|
5786
5910
|
_currentId = null;
|
|
5787
|
-
/** Visible ratio per message id, from the intersection observer. */
|
|
5911
|
+
/** Visible ratio per message id in the reading band, from the intersection observer. */
|
|
5788
5912
|
_visible = /* @__PURE__ */ new Map();
|
|
5913
|
+
/** The bubbles on screen at all, from the second observer; only the last one is read. */
|
|
5914
|
+
_onScreen = /* @__PURE__ */ new Set();
|
|
5915
|
+
_onScreenObserver = null;
|
|
5916
|
+
/** The mark a jump asked for, held until the transcript has stopped moving. */
|
|
5917
|
+
_held = null;
|
|
5918
|
+
_settleTimer = null;
|
|
5919
|
+
_settleSurface = null;
|
|
5920
|
+
/** Re-alignments done for the jump in flight. */
|
|
5921
|
+
_alignPasses = 0;
|
|
5922
|
+
/** The custom properties written on this element, so each is written only when it changes. */
|
|
5923
|
+
_written = /* @__PURE__ */ new Map();
|
|
5789
5924
|
_onConfigChange = () => this._rebuild();
|
|
5925
|
+
_onSurfaceScroll = () => this._bumpSettle();
|
|
5790
5926
|
/** The id of the message the rail currently marks, or `null`. */
|
|
5791
5927
|
get currentMessageId() {
|
|
5792
5928
|
return this._currentId;
|
|
@@ -5830,17 +5966,26 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5830
5966
|
return;
|
|
5831
5967
|
}
|
|
5832
5968
|
if (typeof MutationObserver !== "undefined") {
|
|
5833
|
-
this._mutations = new MutationObserver(() =>
|
|
5969
|
+
this._mutations = new MutationObserver((records) => {
|
|
5970
|
+
if (records.some((record) => !this.contains(record.target))) this._queueRebuild();
|
|
5971
|
+
});
|
|
5834
5972
|
this._mutations.observe(this._host, { childList: true, subtree: true, characterData: true });
|
|
5835
5973
|
}
|
|
5974
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
5975
|
+
this._resizes = new ResizeObserver(() => this._layout());
|
|
5976
|
+
this._resizes.observe(this._host);
|
|
5977
|
+
const viewport = this._viewport();
|
|
5978
|
+
if (viewport) this._resizes.observe(viewport);
|
|
5979
|
+
}
|
|
5836
5980
|
this._rebuild();
|
|
5837
5981
|
}
|
|
5838
5982
|
_detach() {
|
|
5839
5983
|
this._mutations?.disconnect();
|
|
5840
5984
|
this._mutations = null;
|
|
5841
|
-
this.
|
|
5842
|
-
this.
|
|
5843
|
-
this.
|
|
5985
|
+
this._resizes?.disconnect();
|
|
5986
|
+
this._resizes = null;
|
|
5987
|
+
this._unobserve();
|
|
5988
|
+
this._release();
|
|
5844
5989
|
this._host = null;
|
|
5845
5990
|
}
|
|
5846
5991
|
/** Coalesce a burst of mutations (a streaming reply is one per token) into one rebuild. */
|
|
@@ -5881,6 +6026,15 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5881
6026
|
const excerpt = this._excerpt(bubble);
|
|
5882
6027
|
return excerpt ? `${role}: ${excerpt}` : role;
|
|
5883
6028
|
}
|
|
6029
|
+
_list() {
|
|
6030
|
+
return this.querySelector(":scope > .aparte-scroll-rail__list");
|
|
6031
|
+
}
|
|
6032
|
+
/**
|
|
6033
|
+
* Reconcile the ticks with the bubbles, by message id. A tick that already exists is
|
|
6034
|
+
* kept — the same node, so the focus on it, its hover and its tooltip survive a turn
|
|
6035
|
+
* being appended or a reply streaming beside it — moved only when its order changed,
|
|
6036
|
+
* and dropped when its bubble left.
|
|
6037
|
+
*/
|
|
5884
6038
|
_rebuild() {
|
|
5885
6039
|
const bubbles = this._bubbles();
|
|
5886
6040
|
const locale = resolveConfig(this).getLocale();
|
|
@@ -5888,45 +6042,71 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5888
6042
|
if (bubbles.length < 2) {
|
|
5889
6043
|
this.setAttribute("data-empty", "");
|
|
5890
6044
|
this.replaceChildren();
|
|
5891
|
-
this.
|
|
5892
|
-
this._intersections = null;
|
|
5893
|
-
this._visible.clear();
|
|
6045
|
+
this._unobserve();
|
|
5894
6046
|
this._currentId = null;
|
|
6047
|
+
this._write("--aparte-scroll-rail-hit-size", "");
|
|
5895
6048
|
return;
|
|
5896
6049
|
}
|
|
5897
6050
|
this.removeAttribute("data-empty");
|
|
5898
|
-
|
|
5899
|
-
list
|
|
6051
|
+
let list = this._list();
|
|
6052
|
+
if (!list) {
|
|
6053
|
+
list = document.createElement("ol");
|
|
6054
|
+
list.className = "aparte-scroll-rail__list";
|
|
6055
|
+
this.replaceChildren(list);
|
|
6056
|
+
}
|
|
6057
|
+
const existing = /* @__PURE__ */ new Map();
|
|
6058
|
+
for (const item of Array.from(list.children)) {
|
|
6059
|
+
const id = item.firstElementChild instanceof HTMLElement ? item.firstElementChild.dataset["messageId"] : void 0;
|
|
6060
|
+
if (id) existing.set(id, item);
|
|
6061
|
+
}
|
|
5900
6062
|
const known = /* @__PURE__ */ new Set();
|
|
6063
|
+
let index = 0;
|
|
5901
6064
|
for (const bubble of bubbles) {
|
|
5902
6065
|
const id = bubble.getAttribute("message-id");
|
|
5903
6066
|
known.add(id);
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
6067
|
+
let item = existing.get(id);
|
|
6068
|
+
if (item) {
|
|
6069
|
+
existing.delete(id);
|
|
6070
|
+
} else {
|
|
6071
|
+
item = document.createElement("li");
|
|
6072
|
+
const tick2 = document.createElement("button");
|
|
6073
|
+
tick2.type = "button";
|
|
6074
|
+
tick2.className = "aparte-scroll-rail__tick";
|
|
6075
|
+
tick2.dataset["messageId"] = id;
|
|
6076
|
+
item.appendChild(tick2);
|
|
6077
|
+
}
|
|
6078
|
+
const tick = item.firstElementChild;
|
|
5909
6079
|
const label = this._tickLabel(bubble);
|
|
5910
|
-
tick.
|
|
5911
|
-
|
|
6080
|
+
if (tick.getAttribute("aria-label") !== label) {
|
|
6081
|
+
tick.setAttribute("aria-label", label);
|
|
6082
|
+
tick.title = label;
|
|
6083
|
+
}
|
|
5912
6084
|
if (id === this._currentId) tick.setAttribute("aria-current", "true");
|
|
5913
|
-
|
|
5914
|
-
list.
|
|
6085
|
+
else tick.removeAttribute("aria-current");
|
|
6086
|
+
const at = list.children[index] ?? null;
|
|
6087
|
+
if (at !== item) list.insertBefore(item, at);
|
|
6088
|
+
index++;
|
|
5915
6089
|
}
|
|
5916
|
-
|
|
6090
|
+
for (const stale of existing.values()) stale.remove();
|
|
5917
6091
|
if (this._currentId && !known.has(this._currentId)) this._currentId = null;
|
|
5918
|
-
this._observe(bubbles);
|
|
6092
|
+
if (!sameElements(this._observedBubbles, bubbles)) this._observe(bubbles);
|
|
6093
|
+
this._layout();
|
|
5919
6094
|
}
|
|
5920
6095
|
/**
|
|
5921
|
-
* Which tick is current is read, not computed: the scroll surface is the root
|
|
5922
|
-
*
|
|
5923
|
-
*
|
|
5924
|
-
*
|
|
6096
|
+
* Which tick is current is read, not computed: the scroll surface is the root and an
|
|
6097
|
+
* intersection observer reports which bubbles reach a reading band — the top 30% of
|
|
6098
|
+
* the surface, from its very top so that the bubble a jump aligned there counts. The
|
|
6099
|
+
* rule is a scrollspy's: the LAST question that has reached the band is the one the
|
|
6100
|
+
* reader is under. "The largest visible share" was the rule before, and at the bottom
|
|
6101
|
+
* of a thread of short turns, where three questions sit in the band, it named the
|
|
6102
|
+
* first of them. Scroll arithmetic was the alternative, and every scroll-position
|
|
6103
|
+
* rule in the viewport carries a regression note; the one read here is the bottom,
|
|
6104
|
+
* because at the bottom the reader is under the latest question whatever the band
|
|
6105
|
+
* holds, and the observer has nothing to report on the last few pixels.
|
|
5925
6106
|
*/
|
|
5926
6107
|
_observe(bubbles) {
|
|
5927
|
-
this.
|
|
5928
|
-
this.
|
|
5929
|
-
this._visible.clear();
|
|
6108
|
+
this._unobserve();
|
|
6109
|
+
this._observedBubbles = bubbles;
|
|
5930
6110
|
if (typeof IntersectionObserver === "undefined") return;
|
|
5931
6111
|
const root = this._scrollSurface();
|
|
5932
6112
|
if (!root) return;
|
|
@@ -5935,31 +6115,143 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5935
6115
|
const id = entry.target.getAttribute("message-id");
|
|
5936
6116
|
if (id) this._visible.set(id, entry.isIntersecting ? entry.intersectionRatio : 0);
|
|
5937
6117
|
}
|
|
5938
|
-
this._pickCurrent(
|
|
5939
|
-
}, { root, rootMargin: "
|
|
5940
|
-
|
|
6118
|
+
this._pickCurrent();
|
|
6119
|
+
}, { root, rootMargin: "0px 0px -70% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] });
|
|
6120
|
+
this._onScreenObserver = new IntersectionObserver((entries) => {
|
|
6121
|
+
for (const entry of entries) {
|
|
6122
|
+
const id = entry.target.getAttribute("message-id");
|
|
6123
|
+
if (!id) continue;
|
|
6124
|
+
if (entry.isIntersecting) this._onScreen.add(id);
|
|
6125
|
+
else this._onScreen.delete(id);
|
|
6126
|
+
}
|
|
6127
|
+
this._pickCurrent();
|
|
6128
|
+
}, { root, threshold: [0] });
|
|
6129
|
+
for (const bubble of bubbles) {
|
|
6130
|
+
this._intersections.observe(bubble);
|
|
6131
|
+
this._onScreenObserver.observe(bubble);
|
|
6132
|
+
}
|
|
6133
|
+
this._observedSurface = root;
|
|
6134
|
+
root.addEventListener("scroll", this._onRootScroll, { passive: true });
|
|
5941
6135
|
}
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
6136
|
+
_unobserve() {
|
|
6137
|
+
this._intersections?.disconnect();
|
|
6138
|
+
this._intersections = null;
|
|
6139
|
+
this._onScreenObserver?.disconnect();
|
|
6140
|
+
this._onScreenObserver = null;
|
|
6141
|
+
this._observedSurface?.removeEventListener("scroll", this._onRootScroll);
|
|
6142
|
+
this._observedSurface = null;
|
|
6143
|
+
this._observedBubbles = [];
|
|
6144
|
+
this._visible.clear();
|
|
6145
|
+
this._onScreen.clear();
|
|
6146
|
+
}
|
|
6147
|
+
/** A scroll only matters at the bottom edge, where the observer is silent: one pick per frame. */
|
|
6148
|
+
_onRootScroll = () => {
|
|
6149
|
+
if (this._pickQueued) return;
|
|
6150
|
+
this._pickQueued = true;
|
|
6151
|
+
requestAnimationFrame(() => {
|
|
6152
|
+
this._pickQueued = false;
|
|
6153
|
+
this._pickCurrent();
|
|
6154
|
+
});
|
|
6155
|
+
};
|
|
6156
|
+
/** At the bottom edge of a surface that scrolls; a surface with nothing to scroll has no bottom to be at. */
|
|
6157
|
+
_atBottom() {
|
|
6158
|
+
const surface = this._observedSurface;
|
|
6159
|
+
if (!surface) return false;
|
|
6160
|
+
const max = surface.scrollHeight - surface.clientHeight;
|
|
6161
|
+
return max > 0 && max - surface.scrollTop <= 2;
|
|
6162
|
+
}
|
|
6163
|
+
_pickCurrent() {
|
|
6164
|
+
if (this._held) return;
|
|
6165
|
+
const bubbles = this._observedBubbles;
|
|
6166
|
+
if (bubbles.length === 0) return;
|
|
6167
|
+
const lastId = bubbles[bubbles.length - 1].getAttribute("message-id");
|
|
6168
|
+
if (this._atBottom() || this._onScreen.has(lastId)) {
|
|
6169
|
+
this._setCurrent(lastId);
|
|
6170
|
+
return;
|
|
6171
|
+
}
|
|
6172
|
+
let last = null;
|
|
5945
6173
|
for (const bubble of bubbles) {
|
|
5946
6174
|
const id = bubble.getAttribute("message-id");
|
|
5947
|
-
|
|
5948
|
-
if (ratio > bestRatio) {
|
|
5949
|
-
bestRatio = ratio;
|
|
5950
|
-
best = id;
|
|
5951
|
-
}
|
|
6175
|
+
if ((this._visible.get(id) ?? 0) > 0) last = id;
|
|
5952
6176
|
}
|
|
5953
|
-
if (
|
|
6177
|
+
if (last !== null) this._setCurrent(last);
|
|
5954
6178
|
}
|
|
5955
6179
|
_setCurrent(id) {
|
|
5956
6180
|
if (this._currentId === id) return;
|
|
5957
6181
|
this._currentId = id;
|
|
5958
6182
|
for (const tick of this.querySelectorAll(".aparte-scroll-rail__tick")) {
|
|
5959
|
-
if (tick.dataset["messageId"] === id)
|
|
5960
|
-
|
|
6183
|
+
if (tick.dataset["messageId"] === id) {
|
|
6184
|
+
tick.setAttribute("aria-current", "true");
|
|
6185
|
+
this._reveal(tick);
|
|
6186
|
+
} else {
|
|
6187
|
+
tick.removeAttribute("aria-current");
|
|
6188
|
+
}
|
|
5961
6189
|
}
|
|
5962
6190
|
}
|
|
6191
|
+
/** Past the floor the rail scrolls itself: the current tick is kept inside its window. */
|
|
6192
|
+
_reveal(tick) {
|
|
6193
|
+
const room = this.clientHeight;
|
|
6194
|
+
if (!room) return;
|
|
6195
|
+
const top = tick.offsetTop;
|
|
6196
|
+
const bottom = top + tick.offsetHeight;
|
|
6197
|
+
if (top < this.scrollTop) this.scrollTop = top;
|
|
6198
|
+
else if (bottom > this.scrollTop + room) this.scrollTop = bottom - room;
|
|
6199
|
+
}
|
|
6200
|
+
// ─── Geometry ─────────────────────────────────────────────────────────
|
|
6201
|
+
/** Write a custom property on this element only when its value changed; '' removes it. */
|
|
6202
|
+
_write(name, value) {
|
|
6203
|
+
if (this._written.get(name) === value) return;
|
|
6204
|
+
this._written.set(name, value);
|
|
6205
|
+
if (value) this.style.setProperty(name, value);
|
|
6206
|
+
else this.style.removeProperty(name);
|
|
6207
|
+
}
|
|
6208
|
+
/**
|
|
6209
|
+
* The three measurements the stylesheet positions the rail by, and the pitch.
|
|
6210
|
+
*
|
|
6211
|
+
* The scrollbar: `offsetWidth − clientWidth` of the scroll surface is the width of a
|
|
6212
|
+
* classic bar and 0 for an overlay one, so the rail sits clear of the bar exactly
|
|
6213
|
+
* where there is one. The extent: the transcript's box within the host, so the rail
|
|
6214
|
+
* spans the messages and not the composer under them. The pitch: the stylesheet's
|
|
6215
|
+
* 24px targets whenever `count × 24` fits the rail; otherwise the pitch that does,
|
|
6216
|
+
* never under `MIN_PITCH` — the rail sets `--aparte-scroll-rail-hit-size` on itself
|
|
6217
|
+
* and the gap, the padding and the zone all follow it, as they do for a consumer's
|
|
6218
|
+
* value. Past the floor the rail scrolls (`_reveal`), and the arrows still walk it.
|
|
6219
|
+
*/
|
|
6220
|
+
_layout() {
|
|
6221
|
+
const host = this._host;
|
|
6222
|
+
const viewport = this._viewport();
|
|
6223
|
+
const surface = this._scrollSurface();
|
|
6224
|
+
if (host && surface) {
|
|
6225
|
+
this._write("--aparte-scroll-rail-bar", `${Math.max(0, surface.offsetWidth - surface.clientWidth)}px`);
|
|
6226
|
+
}
|
|
6227
|
+
if (host && viewport) {
|
|
6228
|
+
const v2 = viewport.getBoundingClientRect();
|
|
6229
|
+
const h2 = host.getBoundingClientRect();
|
|
6230
|
+
if (v2.height > 0 && h2.height > 0) {
|
|
6231
|
+
this._write("--aparte-scroll-rail-block-start", `${Math.max(0, Math.round(v2.top - h2.top))}px`);
|
|
6232
|
+
this._write("--aparte-scroll-rail-block-end", `${Math.max(0, Math.round(h2.bottom - v2.bottom))}px`);
|
|
6233
|
+
}
|
|
6234
|
+
}
|
|
6235
|
+
const count = this._list()?.children.length ?? 0;
|
|
6236
|
+
const height = this.clientHeight;
|
|
6237
|
+
if (!count || !height) {
|
|
6238
|
+
this._write("--aparte-scroll-rail-hit-size", "");
|
|
6239
|
+
this._write("--aparte-scroll-rail-gap", "");
|
|
6240
|
+
return;
|
|
6241
|
+
}
|
|
6242
|
+
const above = host ?? document.documentElement;
|
|
6243
|
+
const aboveStyle = getComputedStyle(above);
|
|
6244
|
+
const hit = parseFloat(aboveStyle.getPropertyValue("--aparte-scroll-rail-hit-size")) || 24;
|
|
6245
|
+
const thickness = parseFloat(aboveStyle.getPropertyValue("--aparte-scroll-rail-tick-thickness")) || 2;
|
|
6246
|
+
if (count * hit <= height) {
|
|
6247
|
+
this._write("--aparte-scroll-rail-hit-size", "");
|
|
6248
|
+
this._write("--aparte-scroll-rail-gap", "");
|
|
6249
|
+
return;
|
|
6250
|
+
}
|
|
6251
|
+
const pitch = Math.max(MIN_PITCH, Math.floor(height / count));
|
|
6252
|
+
this._write("--aparte-scroll-rail-hit-size", `${pitch}px`);
|
|
6253
|
+
this._write("--aparte-scroll-rail-gap", `${Math.max(0, pitch - thickness)}px`);
|
|
6254
|
+
}
|
|
5963
6255
|
// ─── Interaction ──────────────────────────────────────────────────────
|
|
5964
6256
|
_onClick = (e) => {
|
|
5965
6257
|
const tick = e.target.closest(".aparte-scroll-rail__tick");
|
|
@@ -5981,6 +6273,16 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5981
6273
|
/**
|
|
5982
6274
|
* Scroll the transcript to a message. Announced first, cancelable; then a
|
|
5983
6275
|
* `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
|
|
6276
|
+
* The clicked tick is the mark until the transcript has stopped moving: while a
|
|
6277
|
+
* smooth scroll crosses other questions the band would name each in turn, and it
|
|
6278
|
+
* ended on the wrong one two times in three before this hold.
|
|
6279
|
+
*
|
|
6280
|
+
* Where the scroll lands is checked once it has settled, and corrected. The bubbles
|
|
6281
|
+
* carry `content-visibility: auto`, so a bubble far from the reader has an estimated
|
|
6282
|
+
* height until it is rendered: the scroll aims at an estimate, the bubbles above it
|
|
6283
|
+
* take their real size on the way, and the message ends up above or below the top —
|
|
6284
|
+
* measured from 36px to 1,213px off, in all three engines, the further the worse.
|
|
6285
|
+
* A second `scrollIntoView` from a rendered neighbourhood lands; a few are allowed.
|
|
5984
6286
|
*/
|
|
5985
6287
|
jumpTo(messageId) {
|
|
5986
6288
|
const announced = this.dispatchEvent(new CustomEvent("aparte-scroll-rail-jump", {
|
|
@@ -5990,13 +6292,61 @@ class AparteScrollRail extends HTMLElement {
|
|
|
5990
6292
|
cancelable: true
|
|
5991
6293
|
}));
|
|
5992
6294
|
if (!announced) return;
|
|
5993
|
-
const
|
|
5994
|
-
const bubble = viewport?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(messageId)}"]`);
|
|
6295
|
+
const bubble = this._bubbleOf(messageId);
|
|
5995
6296
|
if (!bubble) return;
|
|
6297
|
+
this._scrollTo(bubble);
|
|
6298
|
+
this._setCurrent(messageId);
|
|
6299
|
+
this._hold(messageId);
|
|
6300
|
+
}
|
|
6301
|
+
_bubbleOf(messageId) {
|
|
6302
|
+
return this._viewport()?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(messageId)}"]`) ?? null;
|
|
6303
|
+
}
|
|
6304
|
+
_scrollTo(bubble) {
|
|
5996
6305
|
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
5997
6306
|
bubble.scrollIntoView({ block: "start", behavior: reduced ? "auto" : "smooth" });
|
|
5998
|
-
this._setCurrent(messageId);
|
|
5999
6307
|
}
|
|
6308
|
+
/** Keep `id` as the mark until the scroll surface has been still for `SETTLE_MS`. */
|
|
6309
|
+
_hold(id) {
|
|
6310
|
+
this._release();
|
|
6311
|
+
this._held = id;
|
|
6312
|
+
this._alignPasses = 0;
|
|
6313
|
+
this._settleSurface = this._scrollSurface();
|
|
6314
|
+
this._settleSurface?.addEventListener("scroll", this._onSurfaceScroll);
|
|
6315
|
+
this._bumpSettle();
|
|
6316
|
+
}
|
|
6317
|
+
_bumpSettle() {
|
|
6318
|
+
if (this._settleTimer !== null) clearTimeout(this._settleTimer);
|
|
6319
|
+
this._settleTimer = setTimeout(() => this._settle(), SETTLE_MS);
|
|
6320
|
+
}
|
|
6321
|
+
/** The transcript is still: land on the message if the scroll did not, else release the hold. */
|
|
6322
|
+
_settle() {
|
|
6323
|
+
const id = this._held;
|
|
6324
|
+
const surface = this._settleSurface;
|
|
6325
|
+
const bubble = id ? this._bubbleOf(id) : null;
|
|
6326
|
+
if (id && bubble && surface && this._alignPasses < MAX_ALIGN_PASSES) {
|
|
6327
|
+
const off = bubble.getBoundingClientRect().top - surface.getBoundingClientRect().top;
|
|
6328
|
+
if (Math.abs(off) > ALIGN_TOLERANCE) {
|
|
6329
|
+
this._alignPasses++;
|
|
6330
|
+
this._scrollTo(bubble);
|
|
6331
|
+
this._bumpSettle();
|
|
6332
|
+
return;
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
this._release();
|
|
6336
|
+
if (id) this._setCurrent(id);
|
|
6337
|
+
}
|
|
6338
|
+
_release() {
|
|
6339
|
+
if (this._settleTimer !== null) clearTimeout(this._settleTimer);
|
|
6340
|
+
this._settleTimer = null;
|
|
6341
|
+
this._settleSurface?.removeEventListener("scroll", this._onSurfaceScroll);
|
|
6342
|
+
this._settleSurface = null;
|
|
6343
|
+
this._held = null;
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
function sameElements(a4, b2) {
|
|
6347
|
+
if (a4.length !== b2.length) return false;
|
|
6348
|
+
for (let i2 = 0; i2 < a4.length; i2++) if (a4[i2] !== b2[i2]) return false;
|
|
6349
|
+
return true;
|
|
6000
6350
|
}
|
|
6001
6351
|
if (!customElements.get("aparte-scroll-rail")) {
|
|
6002
6352
|
customElements.define("aparte-scroll-rail", AparteScrollRail);
|
|
@@ -6217,7 +6567,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6217
6567
|
* nothing focusable in it keeps the focus where it was, which is the honest outcome.
|
|
6218
6568
|
*/
|
|
6219
6569
|
_focusFirst() {
|
|
6220
|
-
this
|
|
6570
|
+
visibleFocusable(this)[0]?.focus();
|
|
6221
6571
|
}
|
|
6222
6572
|
_syncScrim() {
|
|
6223
6573
|
const wanted = this.drawer && !this.collapsed;
|
|
@@ -6240,7 +6590,7 @@ class AparteSidebar extends HTMLElement {
|
|
|
6240
6590
|
return;
|
|
6241
6591
|
}
|
|
6242
6592
|
if (e.key === "Tab") {
|
|
6243
|
-
const focusable =
|
|
6593
|
+
const focusable = visibleFocusable(this);
|
|
6244
6594
|
if (focusable.length === 0) return;
|
|
6245
6595
|
const first = focusable[0];
|
|
6246
6596
|
const last = focusable[focusable.length - 1];
|
|
@@ -6304,6 +6654,9 @@ const FOCUSABLE = [
|
|
|
6304
6654
|
"textarea:not([disabled])",
|
|
6305
6655
|
'[tabindex]:not([tabindex="-1"])'
|
|
6306
6656
|
].join(",");
|
|
6657
|
+
function visibleFocusable(root) {
|
|
6658
|
+
return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
|
|
6659
|
+
}
|
|
6307
6660
|
function nearestSidebar(control) {
|
|
6308
6661
|
return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
|
|
6309
6662
|
}
|