@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.
Files changed (42) hide show
  1. package/dist/client/aparte-client.d.ts +57 -2
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/lifecycle-events.d.ts +8 -1
  4. package/dist/client/lifecycle-events.d.ts.map +1 -1
  5. package/dist/client/stream-adapter.d.ts +8 -0
  6. package/dist/client/stream-adapter.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +6 -0
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  10. package/dist/components/composer/aparte-composer-input.d.ts +1 -1
  11. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  12. package/dist/components/conversation-list/aparte-conversation-list.d.ts +5 -0
  13. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  14. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +91 -10
  15. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  16. package/dist/components/status/aparte-chat-status.d.ts +41 -3
  17. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  18. package/dist/components/viewport/aparte-chat-viewport.d.ts +8 -1
  19. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  20. package/dist/conversations/conversation-manager.d.ts +29 -0
  21. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  22. package/dist/conversations/index.d.ts +1 -1
  23. package/dist/conversations/index.d.ts.map +1 -1
  24. package/dist/custom-elements.json +10916 -10397
  25. package/dist/{dialog-triggers-D8wNICrT.js → dialog-triggers-cYGTlfZf.js} +128 -30
  26. package/dist/{dialog-triggers-D8wNICrT.js.map → dialog-triggers-cYGTlfZf.js.map} +1 -1
  27. package/dist/elicitation/panel.d.ts.map +1 -1
  28. package/dist/index.css +162 -40
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +447 -94
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.node.d.ts +1 -1
  34. package/dist/index.node.d.ts.map +1 -1
  35. package/dist/index.node.js +1 -1
  36. package/dist/index.node.js.map +1 -1
  37. package/dist/interop/element-props.d.ts.map +1 -1
  38. package/dist/primitives/select/aparte-select.d.ts +25 -3
  39. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  40. package/dist/types/events.d.ts +4 -2
  41. package/dist/types/events.d.ts.map +1 -1
  42. 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-D8wNICrT.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-D8wNICrT.js";
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
- this._isOpen = this.hasAttribute("open");
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
- this._isOpen = this.hasAttribute("open");
230
- if (this._isOpen) {
231
- this._dropdown?.removeAttribute("hidden");
232
- this._searchInput?.focus();
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
- const searchInput = document.createElement("input");
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._trigger?.removeAttribute("aria-activedescendant");
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._trigger?.setAttribute("aria-activedescendant", active.id);
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._trigger?.removeAttribute("aria-activedescendant");
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._trigger?.setAttribute("aria-expanded", "true");
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._trigger?.setAttribute("aria-expanded", "false");
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 when the
560
- * placeholder changes. Written only when different: the mutation observer watches
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 name = this.getAttribute("aria-label") || this.getAttribute("placeholder") || "Select...";
565
- for (const el2 of [this._trigger, this.querySelector(".aparte-select-options")]) {
566
- if (el2 && el2.getAttribute("aria-label") !== name) el2.setAttribute("aria-label", name);
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
- if (this.hasAttribute("text")) {
2315
- const textEl = this.querySelector(".aparte-status-text");
2316
- if (textEl) textEl.textContent = text;
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
- if (textEl) textEl.textContent = text ?? "";
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
- /** The overlay shell's element children that are not this viewport — the floating bottom stack. */
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
- targetId: this.targetId ?? void 0,
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
- this.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
4736
- tile.setAttribute("role", "button");
4737
- tile.setAttribute("tabindex", "0");
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: tile.getAttribute("title") ?? "" }
4850
+ detail: { url: img.src, name: images[i2]?.name ?? "" }
4745
4851
  }));
4746
4852
  };
4747
- tile.addEventListener("click", open);
4748
- tile.addEventListener("keydown", (e) => {
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", () => this._finishRename(true, false));
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, viaKeyboard) {
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(() => this._queueRebuild());
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._intersections?.disconnect();
5842
- this._intersections = null;
5843
- this._visible.clear();
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._intersections?.disconnect();
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
- const list = document.createElement("ol");
5899
- list.className = "aparte-scroll-rail__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
- const item = document.createElement("li");
5905
- const tick = document.createElement("button");
5906
- tick.type = "button";
5907
- tick.className = "aparte-scroll-rail__tick";
5908
- tick.dataset["messageId"] = id;
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.setAttribute("aria-label", label);
5911
- tick.title = label;
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
- item.appendChild(tick);
5914
- list.appendChild(item);
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
- this.replaceChildren(list);
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, and
5922
- * the bubble with the largest visible share of a reading band near the top wins.
5923
- * Scroll arithmetic was the alternative, and every scroll-position rule in the
5924
- * viewport carries a regression note — this one has none to carry.
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._intersections?.disconnect();
5928
- this._intersections = null;
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(bubbles);
5939
- }, { root, rootMargin: "-15% 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] });
5940
- for (const bubble of bubbles) this._intersections.observe(bubble);
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
- _pickCurrent(bubbles) {
5943
- let best = null;
5944
- let bestRatio = 0;
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
- const ratio = this._visible.get(id) ?? 0;
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 (best !== null) this._setCurrent(best);
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) tick.setAttribute("aria-current", "true");
5960
- else tick.removeAttribute("aria-current");
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 viewport = this._viewport();
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.querySelector(FOCUSABLE)?.focus();
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 = Array.from(this.querySelectorAll(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
  }