@lo-tek/interfaces 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +137 -2
  2. package/README.md +4 -3
  3. package/agent/astro.md +1 -1
  4. package/agent/bundles.md +1 -1
  5. package/agent/datastar.md +48 -48
  6. package/agent/html.md +51 -51
  7. package/agent/react.md +1 -1
  8. package/agent/templates.md +49 -49
  9. package/core/lotek-core.wasm +0 -0
  10. package/core/lotek.wasm +0 -0
  11. package/dist/components/default-texts.d.ts +6 -0
  12. package/dist/components/default-texts.d.ts.map +1 -1
  13. package/dist/components/default-texts.js +6 -0
  14. package/dist/components/default-texts.js.map +1 -1
  15. package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -1
  16. package/dist/components/frame/dialog/lt-dialog.js +5 -2
  17. package/dist/components/frame/dialog/lt-dialog.js.map +1 -1
  18. package/dist/components/frame/error-summary/error-summary.d.ts +15 -2
  19. package/dist/components/frame/error-summary/error-summary.d.ts.map +1 -1
  20. package/dist/components/frame/error-summary/error-summary.js +33 -11
  21. package/dist/components/frame/error-summary/error-summary.js.map +1 -1
  22. package/dist/components/frame/error-summary/lt-error-summary.d.ts +11 -0
  23. package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -1
  24. package/dist/components/frame/error-summary/lt-error-summary.js +114 -8
  25. package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -1
  26. package/dist/components/frame/field/lt-field.d.ts.map +1 -1
  27. package/dist/components/frame/field/lt-field.js +34 -14
  28. package/dist/components/frame/field/lt-field.js.map +1 -1
  29. package/dist/components/frame/popover/lt-popover.d.ts +6 -2
  30. package/dist/components/frame/popover/lt-popover.d.ts.map +1 -1
  31. package/dist/components/frame/popover/lt-popover.js +44 -14
  32. package/dist/components/frame/popover/lt-popover.js.map +1 -1
  33. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -1
  34. package/dist/components/frame/session-timeout/lt-session-timeout.js +43 -6
  35. package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -1
  36. package/dist/components/frame/suggestion/lt-suggestion.d.ts +8 -2
  37. package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -1
  38. package/dist/components/frame/suggestion/lt-suggestion.js +56 -37
  39. package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -1
  40. package/dist/components/frame/tabs/lt-tabs.d.ts +32 -10
  41. package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -1
  42. package/dist/components/frame/tabs/lt-tabs.js +85 -21
  43. package/dist/components/frame/tabs/lt-tabs.js.map +1 -1
  44. package/dist/components/host-element.d.ts +49 -0
  45. package/dist/components/host-element.d.ts.map +1 -1
  46. package/dist/components/host-element.js +74 -0
  47. package/dist/components/host-element.js.map +1 -1
  48. package/dist/components/standalone/connection-status/connection-status.d.ts +5 -1
  49. package/dist/components/standalone/connection-status/connection-status.d.ts.map +1 -1
  50. package/dist/components/standalone/connection-status/connection-status.js +5 -1
  51. package/dist/components/standalone/connection-status/connection-status.js.map +1 -1
  52. package/dist/components/standalone/connection-status/lt-connection-status.d.ts +11 -6
  53. package/dist/components/standalone/connection-status/lt-connection-status.d.ts.map +1 -1
  54. package/dist/components/standalone/connection-status/lt-connection-status.js +34 -10
  55. package/dist/components/standalone/connection-status/lt-connection-status.js.map +1 -1
  56. package/dist/diagnostics/core.d.ts +34 -0
  57. package/dist/diagnostics/core.d.ts.map +1 -1
  58. package/dist/diagnostics/core.js +14 -2
  59. package/dist/diagnostics/core.js.map +1 -1
  60. package/dist/lotek.css +1 -1
  61. package/dist/lt.d.ts +1 -1
  62. package/dist/react.d.ts +2 -0
  63. package/dist/react.d.ts.map +1 -1
  64. package/dist/vocabulary/classes.d.ts.map +1 -1
  65. package/dist/vocabulary/classes.js +34 -0
  66. package/dist/vocabulary/classes.js.map +1 -1
  67. package/dist/vocabulary/elements.d.ts.map +1 -1
  68. package/dist/vocabulary/elements.js +87 -7
  69. package/dist/vocabulary/elements.js.map +1 -1
  70. package/dist/vocabulary/types.d.ts +54 -0
  71. package/dist/vocabulary/types.d.ts.map +1 -1
  72. package/live-templates.xml +3 -2
  73. package/manifest/manifest.json +133 -8
  74. package/manifest/manifest.schema.json +73 -0
  75. package/package.json +2 -1
  76. package/src/components/default-texts.ts +7 -0
  77. package/src/components/frame/dialog/lt-dialog.ts +5 -2
  78. package/src/components/frame/error-summary/error-summary.ts +45 -11
  79. package/src/components/frame/error-summary/lt-error-summary.ts +121 -7
  80. package/src/components/frame/field/lt-field.ts +65 -28
  81. package/src/components/frame/popover/lt-popover.ts +43 -12
  82. package/src/components/frame/session-timeout/lt-session-timeout.ts +40 -6
  83. package/src/components/frame/suggestion/lt-suggestion.ts +57 -36
  84. package/src/components/frame/tabs/lt-tabs.ts +114 -38
  85. package/src/components/host-element.ts +93 -0
  86. package/src/components/standalone/connection-status/connection-status.ts +7 -1
  87. package/src/components/standalone/connection-status/lt-connection-status.ts +36 -10
  88. package/src/diagnostics/core.ts +19 -2
  89. package/src/vocabulary/classes.ts +34 -0
  90. package/src/vocabulary/elements.ts +87 -7
  91. package/src/vocabulary/types.ts +50 -0
  92. package/web-types.json +1 -1
@@ -270,11 +270,30 @@ export class LtSessionTimeout extends HostElement {
270
270
  * dialog, without the component having to hold on to a particular node.
271
271
  */
272
272
  this.addEventListener("close", this.handleClose, true)
273
- this.openObserver = new MutationObserver(() => this.repairOpen())
273
+ this.openObserver = new MutationObserver(() => {
274
+ this.repairOpen()
275
+ /*
276
+ * A morph of the open warning takes the role and the name the component
277
+ * wrote, since the renderer did not send them, and the screen reader had
278
+ * a modal dialog without a name until the warning closed. They are
279
+ * written again while it shows. Such a morph also removes `open`, which
280
+ * is enough to get here; the wiring's own attributes are watched for a
281
+ * morph that leaves `open` alone.
282
+ */
283
+ const dialog = this.dialog
284
+ if (dialog?.matches(":modal")) this.wire(dialog)
285
+ })
274
286
  this.openObserver.observe(this, {
275
287
  subtree: true,
276
288
  attributes: true,
277
- attributeFilter: ["open"],
289
+ attributeFilter: [
290
+ "open",
291
+ "role",
292
+ "class",
293
+ "id",
294
+ "aria-labelledby",
295
+ "aria-label",
296
+ ],
278
297
  })
279
298
  this.ticker = window.setInterval(() => this.tick(), 1000)
280
299
  this.validate()
@@ -509,7 +528,16 @@ export class LtSessionTimeout extends HostElement {
509
528
  // dialog has been replaced in the meantime, it never comes, and then the
510
529
  // memory is released.
511
530
  const current = this.dialog
531
+ /*
532
+ * The renderer replaced the dialog while the warning showed, as
533
+ * `innerHTML` does: still open when it went, so replaced, not closed.
534
+ * Decided in the tick that opens the new one, below; kept in a field, it
535
+ * outlived a `reset()` in between, and the next warning, minutes later,
536
+ * sent no `session-warn`.
537
+ */
538
+ let continuing = false
512
539
  if (this.shownDialog && this.shownDialog !== current) {
540
+ continuing = this.shownDialog.open
513
541
  this.shownDialog = undefined
514
542
  }
515
543
  if (this.shownDialog && !this.shownDialog.open) return
@@ -532,7 +560,7 @@ export class LtSessionTimeout extends HostElement {
532
560
  if (elapsed < this.warnAt && !dialog?.open) return
533
561
 
534
562
  if (!dialog?.open) {
535
- this.openDialog(left)
563
+ this.openDialog(left, continuing)
536
564
  return
537
565
  }
538
566
  /*
@@ -577,7 +605,7 @@ export class LtSessionTimeout extends HostElement {
577
605
  setText(live, sentence.replace(/\s+/g, " ").trim())
578
606
  }
579
607
 
580
- private openDialog(left: number): void {
608
+ private openDialog(left: number, continuing = false): void {
581
609
  const dialog = this.dialog
582
610
  if (!dialog) {
583
611
  // By now the page has long since settled, so an empty element is not a
@@ -606,7 +634,13 @@ export class LtSessionTimeout extends HostElement {
606
634
  // field is in the page, as `<lt-error-summary>` also warns about. The field
607
635
  // can be on either side of the shadow boundary from the component, so only
608
636
  // the descent finds it.
609
- this.previousFocus = deepActiveElement()
637
+ /*
638
+ * A warning that was showing when the renderer replaced the dialog, as
639
+ * `innerHTML` does, never closed: the user is still being warned. Focus
640
+ * goes back to where it was before that warning, not to `<body>`, where
641
+ * it is now, and `session-warn` has been sent already.
642
+ */
643
+ if (!continuing) this.previousFocus = deepActiveElement()
610
644
  /*
611
645
  * The text must be in place before the dialog opens, since `alertdialog` is
612
646
  * announced as it appears. The number in the paragraph is `aria-hidden`, so
@@ -623,7 +657,7 @@ export class LtSessionTimeout extends HostElement {
623
657
  dialog.returnValue = ""
624
658
  dialog.showModal()
625
659
  this.shownDialog = dialog
626
- this.emit("session-warn")
660
+ if (!continuing) this.emit("session-warn")
627
661
  }
628
662
 
629
663
  private closeDialog(reason: string): void {
@@ -7,11 +7,11 @@ import {
7
7
  HostElement,
8
8
  isServerControlled,
9
9
  languageOf,
10
+ rememberedId,
10
11
  SERVER_CONTROLLED,
11
12
  setAttr,
12
13
  setFlag,
13
14
  setText,
14
- uniqueId,
15
15
  warnAboutMarkup,
16
16
  } from "../../host-element.js"
17
17
  import {
@@ -53,6 +53,8 @@ export const LT_SUGGESTION_PARTS = {
53
53
  status: "[role='status']",
54
54
  /** The hidden field that receives the code of the option the user chose. */
55
55
  value: `input.${SUGGESTION_VALUE_CLASS}`,
56
+ /** The label, which gets `for` from the field's id. */
57
+ label: "label",
56
58
  } as const
57
59
 
58
60
  const CONTROL_SELECTOR = LT_SUGGESTION_PARTS.control
@@ -270,11 +272,23 @@ export class LtSuggestion extends HostElement {
270
272
  * callback also fires during the upgrade, before the children exist.
271
273
  */
272
274
  if (name === "prefiltered" && this.observer) {
273
- // The count present when the attribute is set is the starting point,
274
- // and nothing to announce. Without this the first change afterwards
275
- // was taken as the starting point, and was lost.
276
- this.announcedCount = this.prefiltered ? this.visible.length : undefined
277
- this.filter()
275
+ /*
276
+ * After the whole update, as the other repairs: a morph sets the
277
+ * attribute before it has replaced the options, and the count was
278
+ * the old list's. And through `repair()`, which filters only a list
279
+ * that stays open: the filter announces, and a closed list said "One
280
+ * match" to nobody's search.
281
+ */
282
+ queueMicrotask(() => {
283
+ if (!this.observer) return
284
+ // The count present when the attribute is set is the starting point,
285
+ // and nothing to announce. Without this the first change afterwards
286
+ // was taken as the starting point, and was lost.
287
+ this.announcedCount = this.prefiltered ? this.visible.length : undefined
288
+ // The whole repair, not the filter alone: the same update can lock
289
+ // the field, and `repair()` closes the list before it filters.
290
+ this.sync()
291
+ })
278
292
  }
279
293
  }
280
294
 
@@ -388,7 +402,7 @@ export class LtSuggestion extends HostElement {
388
402
  setAttr(
389
403
  labelElement,
390
404
  "id",
391
- this.rememberedId(labelElement, "lt-suggestion-label"),
405
+ rememberedId(this.ids, labelElement, "lt-suggestion-label"),
392
406
  )
393
407
  }
394
408
  if (label !== null) setAttr(list, "aria-label", null)
@@ -399,17 +413,6 @@ export class LtSuggestion extends HostElement {
399
413
  })
400
414
  }
401
415
 
402
- /** The id an element should have: the one it has, otherwise the one the web component created last. */
403
- private rememberedId(element: Element, prefix: string): string {
404
- if (element.id) return element.id
405
- let id = this.ids.get(element)
406
- if (!id) {
407
- id = uniqueId(prefix)
408
- this.ids.set(element, id)
409
- }
410
- return id
411
- }
412
-
413
416
  /**
414
417
  * Fills in the wiring where the markup arrived without it. Only what is
415
418
  * missing.
@@ -430,7 +433,7 @@ export class LtSuggestion extends HostElement {
430
433
  if (!control.hasAttribute("aria-autocomplete")) {
431
434
  setAttr(control, "aria-autocomplete", "list")
432
435
  }
433
- const label = this.querySelector("label")
436
+ const label = this.querySelector(LT_SUGGESTION_PARTS.label)
434
437
  const root = this.getRootNode() as Document | ShadowRoot
435
438
 
436
439
  if (label) {
@@ -547,7 +550,7 @@ export class LtSuggestion extends HostElement {
547
550
  const wanted = control.getAttribute("aria-controls")
548
551
  const free = wanted && !root.getElementById?.(wanted)
549
552
  if (free) this.ids.set(list, wanted)
550
- setAttr(list, "id", this.rememberedId(list, "lt-suggestion-list"))
553
+ setAttr(list, "id", rememberedId(this.ids, list, "lt-suggestion-list"))
551
554
  }
552
555
  if (!control.hasAttribute("aria-controls")) {
553
556
  setAttr(control, "aria-controls", list.id)
@@ -567,7 +570,11 @@ export class LtSuggestion extends HostElement {
567
570
  this.roledByMe.add(option)
568
571
  }
569
572
  if (!option.id) {
570
- setAttr(option, "id", this.rememberedId(option, "lt-suggestion-option"))
573
+ setAttr(
574
+ option,
575
+ "id",
576
+ rememberedId(this.ids, option, "lt-suggestion-option"),
577
+ )
571
578
  }
572
579
  // From the start, not only when the list closes: otherwise the options
573
580
  // the server sent were without it until then, while the rest of the list
@@ -600,6 +607,12 @@ export class LtSuggestion extends HostElement {
600
607
  this.writeCode(valueField, this.chosen.value)
601
608
  }
602
609
 
610
+ // A field that became `readonly` or `disabled` while the list was open
611
+ // closes it. Otherwise the repair kept it open, and Escape does nothing in
612
+ // a locked field. First, so nothing below writes for a list that closes.
613
+ if (!isServerControlled(this) && this.locked && this.wantOpen)
614
+ this.setOpen(false)
615
+
603
616
  // An emptied status element gets its text back while the list is open,
604
617
  // where the count is what the user is working on. Not otherwise: every
605
618
  // patch of the region would announce the old count again. And not when
@@ -616,12 +629,17 @@ export class LtSuggestion extends HostElement {
616
629
  setText(status, this.announcedText)
617
630
  }
618
631
 
619
- if (isServerControlled(this)) return
620
-
621
- // A field that became `readonly` or `disabled` while the list was open
622
- // closes it. Otherwise the repair kept it open, and Escape does nothing in
623
- // a locked field.
624
- if (this.locked && this.wantOpen) this.setOpen(false)
632
+ if (isServerControlled(this)) {
633
+ /*
634
+ * The server decides whether the list is open and what is
635
+ * highlighted, not what matches: an update that changes an open list
636
+ * is filtered here too. Before, the list showed every option the
637
+ * server sent, whatever the user had typed, and an emptied list kept
638
+ * the empty message hidden.
639
+ */
640
+ if (this.listElement?.hidden === false) this.filter()
641
+ return
642
+ }
625
643
 
626
644
  if (this.wantOpen !== undefined) {
627
645
  /*
@@ -1276,8 +1294,17 @@ export class LtSuggestion extends HostElement {
1276
1294
 
1277
1295
  private handleFocus = (): void => {
1278
1296
  if (this.locked) return
1279
- this.filter()
1297
+ this.openFiltered()
1298
+ }
1299
+
1300
+ /**
1301
+ * Opens the list, and filters and announces only when it opens. Filtering
1302
+ * first announced "No matches" for a list that `setOpen` then kept closed,
1303
+ * since there was nothing to show.
1304
+ */
1305
+ private openFiltered(): void {
1280
1306
  this.setOpen(true)
1307
+ if (this.wantOpen) this.filter()
1281
1308
  }
1282
1309
 
1283
1310
  private handleFocusOut = (event: FocusEvent): void => {
@@ -1297,10 +1324,7 @@ export class LtSuggestion extends HostElement {
1297
1324
 
1298
1325
  if (event.key === "ArrowDown") {
1299
1326
  event.preventDefault()
1300
- if (!open) {
1301
- this.filter()
1302
- this.setOpen(true)
1303
- }
1327
+ if (!open) this.openFiltered()
1304
1328
  this.markActive(this.activeIndex + 1)
1305
1329
  } else if (event.key === "ArrowUp") {
1306
1330
  event.preventDefault()
@@ -1308,10 +1332,7 @@ export class LtSuggestion extends HostElement {
1308
1332
  // `aria-activedescendant` pointed to an option in a list the field at the
1309
1333
  // same time reported as closed, and the screen reader announced something
1310
1334
  // that was not on screen.
1311
- if (!open) {
1312
- this.filter()
1313
- this.setOpen(true)
1314
- }
1335
+ if (!open) this.openFiltered()
1315
1336
  // If nothing is highlighted, arrow up goes to the last one.
1316
1337
  // `activeIndex - 1` would have given the second to last, since nothing
1317
1338
  // highlighted is -1.
@@ -3,10 +3,14 @@ import {
3
3
  derivedParts,
4
4
  HostElement,
5
5
  isServerControlled,
6
+ markWritten,
6
7
  SERVER_CONTROLLED,
7
8
  setAttr,
8
9
  setFlag,
10
+ stillMarked,
9
11
  uniqueId,
12
+ WRITTEN,
13
+ warnAboutCopiedMark,
10
14
  warnAboutMarkup,
11
15
  } from "../../host-element.js"
12
16
  export const LT_TABS_TAG = "lt-tabs" as const
@@ -168,6 +172,18 @@ function nameAsReferenced(element: Element): string {
168
172
  */
169
173
  type Word = { written: string | null; server: string | null }
170
174
 
175
+ /**
176
+ * Whether a morph from the server has been there since the web component
177
+ * wrote the attribute: the mark it wrote is gone. Only a value that differed
178
+ * from the server's word was marked (`markWritten`); one that did not has
179
+ * no mark to lose, and its absence says nothing. Read as a sign of the
180
+ * server, every unmarked tab in a row of three released a click under
181
+ * `server-controlled` at once.
182
+ */
183
+ function markGone(element: Element, name: string, word: Word): boolean {
184
+ return word.written !== word.server && !stillMarked(element, name)
185
+ }
186
+
171
187
  /**
172
188
  * Keyboard behaviour in a tab list, and the wiring when the markup comes
173
189
  * without it.
@@ -239,22 +255,34 @@ export class LtTabs extends HostElement {
239
255
 
240
256
  private observer?: MutationObserver
241
257
  /**
242
- * The tab the user selected, remembered so a patch cannot take it.
258
+ * The panel the user's tab points to, remembered so a patch cannot take
259
+ * the selection.
243
260
  *
244
- * The node itself, not the index and not `href`. An index is not an
245
- * identity: if the server inserts a tab first in the list, the same index
246
- * points to something else, and the selection jumps to a tab the user never
247
- * pressed.
261
+ * Not the index and not the node. An index is not an identity: if the
262
+ * server inserts a tab first in the list, the same index points to
263
+ * something else. The node is not one either: the links have no id, so
264
+ * Datastar and idiomorph match them by position, and the node the user
265
+ * pressed became the inserted tab. The panel's id is the server's and
266
+ * stays; a tab pointing to another panel is another tab.
248
267
  *
249
- * A morph keeps the links, since they have no id. If the patch replaces the
250
- * link itself, as `innerHTML` does, the tab is a different one, and then
251
- * the web component forgets the selection rather than guessing; the address
252
- * still has it, see `selectFromAddress`.
268
+ * Remembered with the link, `chosenTab`.
253
269
  *
254
270
  * `undefined` means the user has not selected anything yet, and then the
255
271
  * server's markup applies.
256
272
  */
273
+ private chosenTarget?: string
274
+ /**
275
+ * The link the user pressed, with `chosenTarget`. While it is in the tab
276
+ * list and points to the same panel, it is the user's tab, also when
277
+ * another tab points to that panel too. When a morph has turned it into
278
+ * another tab, or dropped it while it kept other links, the tab that points
279
+ * to the panel is found. When the links were replaced, none of the row is
280
+ * left, as `innerHTML` does, and the selection is forgotten rather than
281
+ * guessed; the address still has it, see `selectFromAddress`.
282
+ */
257
283
  private chosenTab?: HTMLElement
284
+ /** The tab list as it was when the user's tab was last found. */
285
+ private chosenRow: HTMLElement[] = []
258
286
  /**
259
287
  * Roles the web component set itself. Tells a bare tab list, where every
260
288
  * link is a tab, from one where the server wrote the roles, where a link
@@ -287,7 +315,7 @@ export class LtTabs extends HostElement {
287
315
  * The tab the user clicked under `server-controlled`, until the server
288
316
  * answers.
289
317
  *
290
- * It is not the user's selection in the sense of `chosenTab`: it is never
318
+ * It is not the user's selection in the sense of `chosenTarget`: it is never
291
319
  * restored against the server's word. It only lasts until someone else has
292
320
  * touched `aria-selected` or `hidden`, added a tab or a panel, removed the
293
321
  * tab, or removed `server-controlled`. Without it, `repair()` read the
@@ -360,6 +388,9 @@ export class LtTabs extends HostElement {
360
388
  // disables the selected tab, the tab stop moves to one that can take
361
389
  // focus. `alt`, `aria-hidden` and `title` change the name of a tab.
362
390
  attributeFilter: [
391
+ // A morph from the server removes the mark, and the web component
392
+ // puts it back: the same markup must give the same page.
393
+ WRITTEN,
363
394
  "aria-selected",
364
395
  "tabindex",
365
396
  "hidden",
@@ -385,7 +416,7 @@ export class LtTabs extends HostElement {
385
416
  // `server-controlled` switched on midway: the web component lets go, and
386
417
  // the next patch decides.
387
418
  if (name === SERVER_CONTROLLED && isServerControlled(this)) {
388
- this.chosenTab = undefined
419
+ this.choose(undefined)
389
420
  // What is there now is the starting point. Without this the tab list
390
421
  // jumped back to the tab the server last named, before the user chose
391
422
  // another. But as when the server answers a click: `aria-selected` the
@@ -426,28 +457,77 @@ export class LtTabs extends HostElement {
426
457
  ).filter((tab) => linkTarget(tab) !== null)
427
458
  }
428
459
 
460
+ /** Remembers the user's tab: the link, and the panel it points to. */
461
+ private choose(tab: HTMLElement | undefined): void {
462
+ this.chosenTab = tab
463
+ this.chosenTarget = tab ? (linkTarget(tab) ?? undefined) : undefined
464
+ this.chosenRow = tab ? this.tabs : []
465
+ }
466
+
467
+ /**
468
+ * Where the user's tab is now, or -1: the link while it points to the same
469
+ * panel; the tab that points to that panel when a morph has turned the
470
+ * link into another tab, or dropped it and kept others (the morphers match
471
+ * links without ids by position, and look ahead when one is removed);
472
+ * none when the links were replaced, with nothing of the row left.
473
+ */
474
+ private chosenIndex(): number {
475
+ const tabs = this.tabs
476
+ const link = this.chosenTab
477
+ if (!link || this.chosenTarget === undefined) return -1
478
+ const at = tabs.indexOf(link)
479
+ if (at >= 0 && linkTarget(link) === this.chosenTarget) {
480
+ this.chosenRow = tabs
481
+ return at
482
+ }
483
+ const morphed = at >= 0 || this.chosenRow.some((tab) => tabs.includes(tab))
484
+ if (!morphed) return -1
485
+ const moved = tabs.findIndex((tab) => linkTarget(tab) === this.chosenTarget)
486
+ if (moved >= 0) {
487
+ this.chosenTab = tabs[moved]
488
+ this.chosenRow = tabs
489
+ }
490
+ return moved
491
+ }
492
+
429
493
  /** The server's word about an attribute, against what the web component last wrote. */
430
494
  private serverWord(
431
495
  memory: WeakMap<Element, Word>,
432
496
  element: Element,
433
- now: string | null,
497
+ name: string,
434
498
  ): string | null {
499
+ const now = element.getAttribute(name)
435
500
  const known = memory.get(element)
436
- if (!known) return now
437
- return now !== known.written ? now : known.server
501
+ if (!known) {
502
+ warnAboutCopiedMark(this, element)
503
+ return now
504
+ }
505
+ // The server's when the value is another, or when a morph from the
506
+ // server has been there since the web component wrote it (`WRITTEN`).
507
+ return now !== known.written || !stillMarked(element, name)
508
+ ? now
509
+ : known.server
438
510
  }
439
511
 
440
512
  /** Is there something on a tab or a panel other than what the web component wrote? */
441
513
  private touchedByOthers(): boolean {
442
514
  return this.tabs.some((tab) => {
443
515
  const selected = this.selectedMemory.get(tab)
444
- if (!selected || tab.getAttribute("aria-selected") !== selected.written) {
516
+ if (
517
+ !selected ||
518
+ tab.getAttribute("aria-selected") !== selected.written ||
519
+ markGone(tab, "aria-selected", selected)
520
+ ) {
445
521
  return true
446
522
  }
447
523
  const panel = this.panelFor(tab)
448
524
  if (!panel) return false
449
525
  const hidden = this.hiddenMemory.get(panel)
450
- return !hidden || panel.getAttribute("hidden") !== hidden.written
526
+ return (
527
+ !hidden ||
528
+ panel.getAttribute("hidden") !== hidden.written ||
529
+ markGone(panel, "hidden", hidden)
530
+ )
451
531
  })
452
532
  }
453
533
 
@@ -476,7 +556,9 @@ export class LtTabs extends HostElement {
476
556
  // A new, bare tab has said nothing. Had it counted, the click's
477
557
  // `aria-selected` was read as the server's, and a later move with
478
558
  // `hidden` alone was undone.
479
- return known ? now !== known.written : now !== null
559
+ return known
560
+ ? now !== known.written || markGone(tab, "aria-selected", known)
561
+ : now !== null
480
562
  })
481
563
  this.hiddenMemory = new WeakMap()
482
564
  if (serverSpoke) {
@@ -484,28 +566,19 @@ export class LtTabs extends HostElement {
484
566
  return
485
567
  }
486
568
  for (const tab of tabs) {
487
- this.selectedMemory.set(tab, {
488
- written: tab.getAttribute("aria-selected"),
489
- server: null,
490
- })
569
+ const written = tab.getAttribute("aria-selected")
570
+ this.selectedMemory.set(tab, { written, server: null })
571
+ markWritten(tab, "aria-selected", written !== null)
491
572
  }
492
573
  }
493
574
 
494
575
  private serverSelected(tab: Element): string | null {
495
- return this.serverWord(
496
- this.selectedMemory,
497
- tab,
498
- tab.getAttribute("aria-selected"),
499
- )
576
+ return this.serverWord(this.selectedMemory, tab, "aria-selected")
500
577
  }
501
578
 
502
579
  /** The server's word about `hidden` on the panel: the form, or `null` for visible. */
503
580
  private serverHidden(panel: Element): string | null {
504
- return this.serverWord(
505
- this.hiddenMemory,
506
- panel,
507
- panel.getAttribute("hidden"),
508
- )
581
+ return this.serverWord(this.hiddenMemory, panel, "hidden")
509
582
  }
510
583
 
511
584
  /**
@@ -879,7 +952,7 @@ export class LtTabs extends HostElement {
879
952
  this.releasePending()
880
953
  }
881
954
 
882
- if (!this.chosenTab || isServerControlled(this)) {
955
+ if (this.chosenTarget === undefined || isServerControlled(this)) {
883
956
  /*
884
957
  * No user selection to restore, so the server's word decides, and it
885
958
  * is written out in full: `aria-selected`, the tab stop and `hidden` in
@@ -898,7 +971,8 @@ export class LtTabs extends HostElement {
898
971
  return
899
972
  }
900
973
 
901
- const index = this.tabs.indexOf(this.chosenTab)
974
+ const chosen = this.chosenTarget
975
+ const index = this.chosenIndex()
902
976
  if (index < 0) {
903
977
  /*
904
978
  * The tab no longer exists. The server has sent something else, and
@@ -917,8 +991,8 @@ export class LtTabs extends HostElement {
917
991
  * loads the panel's content or writes the selection to the address
918
992
  * would never learn that it moved.
919
993
  */
920
- const lostPanel = this.panelFor(this.chosenTab)
921
- this.chosenTab = undefined
994
+ const lostPanel = chosen === undefined ? null : this.byFragment(chosen)
995
+ this.choose(undefined)
922
996
  // The server marked a tab itself. Then that applies, and the panels and
923
997
  // the tab stop are written to match it, even when nothing else gave the
924
998
  // observer another round.
@@ -938,10 +1012,10 @@ export class LtTabs extends HostElement {
938
1012
  const tab = this.tabs[index]
939
1013
  this.apply(index)
940
1014
  if (this.panelFor(tab) === lostPanel) {
941
- this.chosenTab = tab
1015
+ this.choose(tab)
942
1016
  return
943
1017
  }
944
- if (fromAddress >= 0) this.chosenTab = tab
1018
+ if (fromAddress >= 0) this.choose(tab)
945
1019
  this.showInAddress(index)
946
1020
  this.notify(index)
947
1021
  }
@@ -1225,7 +1299,7 @@ export class LtTabs extends HostElement {
1225
1299
  // No memory when the server decides the selection. Without this a
1226
1300
  // selection made while the attribute was there would be restored the
1227
1301
  // moment someone removed it again.
1228
- if (!isServerControlled(this)) this.chosenTab = tabs[index]
1302
+ if (!isServerControlled(this)) this.choose(tabs[index])
1229
1303
  this.apply(index)
1230
1304
  if (isServerControlled(this)) this.pendingTab = tabs[index]
1231
1305
  this.showInAddress(index)
@@ -1275,6 +1349,7 @@ export class LtTabs extends HostElement {
1275
1349
  const server = this.serverSelected(tab)
1276
1350
  setAttr(tab, "aria-selected", String(chosen))
1277
1351
  this.selectedMemory.set(tab, { written: String(chosen), server })
1352
+ markWritten(tab, "aria-selected", String(chosen) !== server)
1278
1353
  setAttr(tab, "tabindex", i === stop ? "0" : "-1")
1279
1354
 
1280
1355
  const panel = this.panelFor(tab)
@@ -1299,6 +1374,7 @@ export class LtTabs extends HostElement {
1299
1374
  if (written === null) setFlag(panel, "hidden", false)
1300
1375
  else setAttr(panel, "hidden", written)
1301
1376
  this.hiddenMemory.set(panel, { written, server })
1377
+ markWritten(panel, "hidden", written !== server)
1302
1378
  }
1303
1379
  /*
1304
1380
  * Only the selected panel is a tab stop, so Tab from the tab list goes
@@ -96,6 +96,79 @@ export function setAttr(
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * The attribute a component marks what it wrote with: the names of the
101
+ * attributes on the element whose value is the component's, separated by
102
+ * spaces.
103
+ *
104
+ * Some attributes are both the server's and the component's: `aria-invalid`
105
+ * on a field, `aria-selected` on a tab. To tell the server's word from its
106
+ * own echo, a component remembered the value it wrote, and read anything
107
+ * else as the server's. A server that sent the same value was never seen: a
108
+ * morph does not touch an attribute whose value is already right, and the
109
+ * component took the server's `aria-invalid="true"` for its own and removed
110
+ * it. The mark is what a morph does touch. The server never sends it, so a
111
+ * morph from the server removes it, and an attribute without the mark is the
112
+ * server's, whatever its value. A script that changes the element without a
113
+ * morph leaves the mark, and is seen as before, by the value.
114
+ *
115
+ * Only what differs from the server's word is marked. A value that is the
116
+ * server's has nothing to tell apart, and markup that is already right, as
117
+ * React renders `lt.tabs()` with the state, must not be touched: hydration
118
+ * reports every attribute it did not render.
119
+ */
120
+ export const WRITTEN = "data-lt-written"
121
+
122
+ /**
123
+ * Marks `name` on `element` as the component's when `mine`, that is, when
124
+ * what the component wrote differs from the server's word, and removes the
125
+ * mark otherwise.
126
+ */
127
+ export function markWritten(
128
+ element: Element,
129
+ name: string,
130
+ mine: boolean,
131
+ ): void {
132
+ const marked = new Set(
133
+ (element.getAttribute(WRITTEN) ?? "").split(/\s+/).filter(Boolean),
134
+ )
135
+ if (mine) marked.add(name)
136
+ else marked.delete(name)
137
+ setAttr(
138
+ element,
139
+ WRITTEN,
140
+ marked.size > 0 ? [...marked].sort().join(" ") : null,
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Warns when `element` carries the mark before the component has written on
146
+ * it: the mark is in the markup it was given, copied into a template from
147
+ * the browser's developer tools. A morph then never removes it, and the
148
+ * component takes the server's word for its own echo again. Called where the
149
+ * component meets the element for the first time; the check cannot wait, so
150
+ * the warning is not checked again when the page has settled.
151
+ */
152
+ export function warnAboutCopiedMark(host: Element, element: Element): void {
153
+ if (!element.hasAttribute(WRITTEN)) return
154
+ warnAboutMarkup(
155
+ host,
156
+ `found ${WRITTEN} in the markup it was given. The browser writes the ` +
157
+ "attribute to tell the component's own values from the server's; " +
158
+ "copied into a template, it makes the component take what the " +
159
+ "server sends for its own. Remove it from the template.",
160
+ () => true,
161
+ )
162
+ }
163
+
164
+ /**
165
+ * Whether the component's mark for `name` is still on `element`: no morph
166
+ * from the server has been there since the component wrote it.
167
+ */
168
+ export function stillMarked(element: Element, name: string): boolean {
169
+ return (element.getAttribute(WRITTEN) ?? "").split(/\s+/).includes(name)
170
+ }
171
+
99
172
  /**
100
173
  * Turns a boolean attribute on or off, without writing when it is already so.
101
174
  *
@@ -175,6 +248,26 @@ export function uniqueId(prefix: string): string {
175
248
  return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
176
249
  }
177
250
 
251
+ /**
252
+ * The id an element should have: the one it has, otherwise the one the
253
+ * component created for that node before. A morph keeps the node and tears
254
+ * the id off, and it gets the same one back, so a screen reader does not
255
+ * follow a reference that changes under it.
256
+ */
257
+ export function rememberedId(
258
+ memory: WeakMap<Element, string>,
259
+ element: Element,
260
+ prefix: string,
261
+ ): string {
262
+ if (element.id) return element.id
263
+ let id = memory.get(element)
264
+ if (!id) {
265
+ id = uniqueId(prefix)
266
+ memory.set(element, id)
267
+ }
268
+ return id
269
+ }
270
+
178
271
  /**
179
272
  * The parts with a role, and those without only when the component set the
180
273
  * roles itself.