@aparte/core 0.16.8 → 0.16.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) 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 +3 -2
  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/custom-elements.json +12484 -12344
  19. package/dist/{dialog-triggers-D8wNICrT.js → dialog-triggers-CApC7K1I.js} +96 -26
  20. package/dist/{dialog-triggers-D8wNICrT.js.map → dialog-triggers-CApC7K1I.js.map} +1 -1
  21. package/dist/elicitation/panel.d.ts.map +1 -1
  22. package/dist/index.css +119 -31
  23. package/dist/index.js +157 -47
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.node.js +1 -1
  26. package/dist/interop/element-props.d.ts.map +1 -1
  27. package/dist/primitives/select/aparte-select.d.ts +25 -3
  28. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  29. package/dist/types/events.d.ts +4 -2
  30. package/dist/types/events.d.ts.map +1 -1
  31. 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-CApC7K1I.js";
2
+ import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-CApC7K1I.js";
3
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
  }
@@ -4012,6 +4100,8 @@ class AparteComposer extends HTMLElement {
4012
4100
  onEvict?.();
4013
4101
  }
4014
4102
  _teardownPanel() {
4103
+ const active = document.activeElement;
4104
+ const hadFocus = active instanceof Node && this.contains(active);
4015
4105
  const existing = this.querySelector("[data-aparte-panel]");
4016
4106
  if (existing) existing.remove();
4017
4107
  this.removeAttribute("data-panel-active");
@@ -4023,7 +4113,7 @@ class AparteComposer extends HTMLElement {
4023
4113
  this._panelOnEvict = null;
4024
4114
  this._panelToken = null;
4025
4115
  this._emit("panel-change", { active: false, submitEnabled: false, mode: "submit" });
4026
- this.focus();
4116
+ if (hadFocus) this.focus();
4027
4117
  }
4028
4118
  /**
4029
4119
  * Update the send button's state while a panel is active.
@@ -4085,7 +4175,14 @@ class AparteComposer extends HTMLElement {
4085
4175
  const detail = {
4086
4176
  content: value,
4087
4177
  timestamp: Date.now(),
4088
- targetId: this.targetId ?? void 0,
4178
+ /*
4179
+ * `_ownTargetId()`, not the bare attribute — the same resolution `cancel()`
4180
+ * uses, and for the same reason. The wrappers all set `target`, so this is
4181
+ * byte-identical there; in RAW core nothing writes it, so every send carried
4182
+ * `targetId: undefined` and a page with two hand-written chats delivered the
4183
+ * reply to whichever one the host resolved first.
4184
+ */
4185
+ targetId: this._ownTargetId(),
4089
4186
  files: this._attachments.length > 0 ? [...this._attachments] : void 0
4090
4187
  };
4091
4188
  this.dispatchEvent(new CustomEvent("aparte-send", {
@@ -4732,20 +4829,22 @@ class AparteComposerAttachments extends HTMLElement {
4732
4829
  });
4733
4830
  });
4734
4831
  if (!resolveConfig(this).getHostHandlers().attachmentPreview) return;
4735
- this.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
4736
- tile.setAttribute("role", "button");
4737
- tile.setAttribute("tabindex", "0");
4832
+ const images = files.filter((f2) => f2.type.startsWith("image/"));
4833
+ this.querySelectorAll(".aparte-thumb--image").forEach((tile, i2) => {
4834
+ const img = tile.querySelector(".aparte-thumbnail__image");
4835
+ if (!img) return;
4836
+ img.setAttribute("role", "button");
4837
+ img.setAttribute("tabindex", "0");
4838
+ img.setAttribute("aria-label", images[i2]?.name ?? "");
4738
4839
  const open = () => {
4739
- const img = tile.querySelector(".aparte-thumbnail__image");
4740
- if (!img) return;
4741
4840
  this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
4742
4841
  bubbles: true,
4743
4842
  composed: true,
4744
- detail: { url: img.src, name: tile.getAttribute("title") ?? "" }
4843
+ detail: { url: img.src, name: images[i2]?.name ?? "" }
4745
4844
  }));
4746
4845
  };
4747
- tile.addEventListener("click", open);
4748
- tile.addEventListener("keydown", (e) => {
4846
+ img.addEventListener("click", open);
4847
+ img.addEventListener("keydown", (e) => {
4749
4848
  const key = e.key;
4750
4849
  if (key !== "Enter" && key !== " ") return;
4751
4850
  e.preventDefault();
@@ -5375,7 +5474,10 @@ class AparteConversationList extends HTMLElement {
5375
5474
  input.dataset["renameId"] = conv.id;
5376
5475
  select.replaceWith(input);
5377
5476
  this._renaming = { id: conv.id, input, done: false };
5378
- input.addEventListener("blur", () => this._finishRename(true, false));
5477
+ input.addEventListener("blur", (e) => {
5478
+ const next = e.relatedTarget;
5479
+ this._finishRename(true, !next || this.contains(next));
5480
+ });
5379
5481
  input.focus();
5380
5482
  input.select();
5381
5483
  }
@@ -5384,23 +5486,28 @@ class AparteConversationList extends HTMLElement {
5384
5486
  * the blur a removal causes cannot commit a second time. The row is re-rendered
5385
5487
  * from the array either way: the list shows data, and the host's re-assignment is
5386
5488
  * what makes the new title appear.
5489
+ *
5490
+ * `restoreFocus` puts the keyboard back on the row: every exit destroys the field
5491
+ * the reader was in, so without it the focus lands on `<body>` and the next Tab
5492
+ * restarts at the top of the page. The caller decides — the blur path says no when
5493
+ * the focus is already heading somewhere live outside this list.
5387
5494
  */
5388
- _finishRename(commit, viaKeyboard) {
5495
+ _finishRename(commit, restoreFocus) {
5389
5496
  const renaming = this._renaming;
5390
5497
  if (!renaming || renaming.done) return;
5391
5498
  renaming.done = true;
5392
5499
  const conv = this._conversations.find((c2) => c2.id === renaming.id);
5393
5500
  const title = renaming.input.value.trim();
5394
5501
  this._render();
5395
- if (viaKeyboard) {
5396
- this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
5397
- }
5398
5502
  if (commit && conv && title && title !== conv.title) {
5399
5503
  this.dispatchEvent(new CustomEvent(
5400
5504
  "aparte-rename-conversation",
5401
5505
  { detail: { id: conv.id, title }, bubbles: true, composed: true }
5402
5506
  ));
5403
5507
  }
5508
+ if (restoreFocus) {
5509
+ this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
5510
+ }
5404
5511
  }
5405
5512
  // ─── Events ───────────────────────────────────────────────────────────
5406
5513
  _onClick = (e) => {
@@ -6217,7 +6324,7 @@ class AparteSidebar extends HTMLElement {
6217
6324
  * nothing focusable in it keeps the focus where it was, which is the honest outcome.
6218
6325
  */
6219
6326
  _focusFirst() {
6220
- this.querySelector(FOCUSABLE)?.focus();
6327
+ visibleFocusable(this)[0]?.focus();
6221
6328
  }
6222
6329
  _syncScrim() {
6223
6330
  const wanted = this.drawer && !this.collapsed;
@@ -6240,7 +6347,7 @@ class AparteSidebar extends HTMLElement {
6240
6347
  return;
6241
6348
  }
6242
6349
  if (e.key === "Tab") {
6243
- const focusable = Array.from(this.querySelectorAll(FOCUSABLE));
6350
+ const focusable = visibleFocusable(this);
6244
6351
  if (focusable.length === 0) return;
6245
6352
  const first = focusable[0];
6246
6353
  const last = focusable[focusable.length - 1];
@@ -6304,6 +6411,9 @@ const FOCUSABLE = [
6304
6411
  "textarea:not([disabled])",
6305
6412
  '[tabindex]:not([tabindex="-1"])'
6306
6413
  ].join(",");
6414
+ function visibleFocusable(root) {
6415
+ return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
6416
+ }
6307
6417
  function nearestSidebar(control) {
6308
6418
  return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
6309
6419
  }