@lo-tek/interfaces 0.37.0 → 0.39.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 +160 -2
  2. package/README.md +4 -3
  3. package/agent/astro.md +1 -1
  4. package/agent/bundles.md +4 -2
  5. package/agent/datastar.md +48 -48
  6. package/agent/html.md +51 -51
  7. package/agent/react.md +6 -5
  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 +52 -0
  45. package/dist/components/host-element.d.ts.map +1 -1
  46. package/dist/components/host-element.js +164 -1
  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 +60 -0
  71. package/dist/vocabulary/types.d.ts.map +1 -1
  72. package/live-templates.xml +3 -2
  73. package/manifest/manifest.json +172 -8
  74. package/manifest/manifest.schema.json +80 -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 +190 -1
  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 +56 -0
  92. package/web-types.json +1 -1
@@ -30,7 +30,8 @@ export const LT_ERROR_SUMMARY_PARTS = {
30
30
  * class, `role="alert"` and `tabindex="-1"` on the box, and the component
31
31
  * leaves them alone. When the markup comes from a template without
32
32
  * JavaScript, the component fills in those three and the class on the
33
- * heading, and sets them back when a patch removes them. The component
33
+ * heading, and sets them back when a patch removes them. Either way, a box
34
+ * without a name gets `aria-labelledby` pointing at its heading. The component
34
35
  * creates no nodes: it used to, and then the heading disappeared on the
35
36
  * first morph in Datastar and never came back.
36
37
  *
@@ -53,6 +54,7 @@ export const LT_ERROR_SUMMARY_PARTS = {
53
54
  * </lt-error-summary>
54
55
  * ```
55
56
  */
57
+
56
58
  /** Decodes a fragment, and returns it untouched if it is not valid. */
57
59
  function decodeFragment(fragment: string): string {
58
60
  try {
@@ -78,7 +80,16 @@ export class LtErrorSummary extends HostElement {
78
80
  * live validation patched the list.
79
81
  */
80
82
  private focusAttempted = false
83
+ /** Where focus was when `hidden` last changed, until the deferred round has run. */
84
+ private focusBefore: Element | null | undefined
81
85
  private observer?: MutationObserver
86
+ /**
87
+ * The `aria-labelledby` the component wrote on the box, or null. Another
88
+ * value there is the server's, and stays; the component's own follows the
89
+ * heading, so a patch that replaces the heading does not leave the name
90
+ * pointing at an id that is gone.
91
+ */
92
+ private labelledByWritten: string | null = null
82
93
 
83
94
  connectedCallback(): void {
84
95
  // One listener on the host, not one per link. The set of links was only
@@ -89,21 +100,67 @@ export class LtErrorSummary extends HostElement {
89
100
  // does not fire in plain DOM. The attributes are the ones the component
90
101
  // fills in itself, so a patch that removes them gets them back. Every
91
102
  // write compares first, or the observer would trigger itself.
92
- this.observer = new MutationObserver(() => this.sync())
103
+ // An id only matters on the heading, which the name points at, and a
104
+ // name only on the box. The same attributes on a link or a field inside
105
+ // the box are not ours to follow.
106
+ this.observer = new MutationObserver((records) => {
107
+ const relevant = records.some((r) => {
108
+ if (r.attributeName === "id")
109
+ return (r.target as Element).matches(LT_ERROR_SUMMARY_PARTS.title)
110
+ if (
111
+ r.attributeName === "aria-label" ||
112
+ r.attributeName === "aria-labelledby"
113
+ )
114
+ return r.target === this
115
+ return true
116
+ })
117
+ if (relevant) this.sync()
118
+ })
93
119
  this.observer.observe(this, {
94
120
  childList: true,
95
121
  subtree: true,
96
122
  attributes: true,
97
- attributeFilter: ["class", "role", "tabindex"],
123
+ attributeFilter: [
124
+ "class",
125
+ "role",
126
+ "tabindex",
127
+ "aria-labelledby",
128
+ "aria-label",
129
+ "id",
130
+ ],
98
131
  })
99
132
  this.sync()
100
133
  }
101
134
 
102
135
  attributeChangedCallback(): void {
103
- // The server can send the box hidden and later only remove `hidden` on
104
- // the same node. Without this, a summary that just became visible never
105
- // gets focus, and that is the whole reason the component exists.
106
- if (this.isConnected) this.sync()
136
+ /*
137
+ * The server can send the box hidden and later only remove `hidden` on
138
+ * the same node. Without this, a summary that just became visible never
139
+ * gets focus, and that is the whole reason the component exists.
140
+ *
141
+ * A microtask later, not now. A morph sets `hidden` in its attribute
142
+ * pass, before it updates the children; focus moved then made the box
143
+ * the focused element, and idiomorph with `ignoreActiveValue` leaves the
144
+ * children of the focused element as they were, so the new errors never
145
+ * arrived. Found by the code review of PR 240.
146
+ */
147
+ // Where focus was when the box was shown. React gives it back there
148
+ // after the commit, and the box takes it anyway; a script that moves it
149
+ // somewhere else in the same turn has decided, and the box leaves it.
150
+ this.focusBefore ??= document.activeElement
151
+ // Hidden: let go of focus now, in the morph's attribute pass, so the
152
+ // morph still updates the content (see `sync()`).
153
+ if (this.hidden) this.blurInside()
154
+ queueMicrotask(() => {
155
+ if (this.isConnected) this.sync()
156
+ this.focusBefore = undefined
157
+ })
158
+ }
159
+
160
+ /** Takes focus off the box, or off a link in it. */
161
+ private blurInside(): void {
162
+ const focused = (this.getRootNode() as Document | ShadowRoot).activeElement
163
+ if (focused instanceof HTMLElement && this.contains(focused)) focused.blur()
107
164
  }
108
165
 
109
166
  disconnectedCallback(): void {
@@ -140,6 +197,49 @@ export class LtErrorSummary extends HostElement {
140
197
 
141
198
  const title = this.querySelector(LT_ERROR_SUMMARY_PARTS.title)
142
199
  if (title) addClass(title, ERROR_SUMMARY_TITLE_CLASS)
200
+
201
+ /*
202
+ * The box takes focus, so it needs a name: without one a screen reader
203
+ * says only "alert" when the focus lands, and the heading is what says
204
+ * how many errors there are. The template writes the heading's id and
205
+ * the box's `aria-labelledby`, as it writes `for` and `id` on a field;
206
+ * `lt.errorSummary({ id })` writes both. The component never writes an
207
+ * id: Datastar and idiomorph replace an element whose id the server did
208
+ * not send, so an id from the component gave the heading a new one on
209
+ * every patch. When the heading has an id and the box no name, the
210
+ * component points `aria-labelledby` at it, and follows a heading a
211
+ * patch has replaced. A blank `aria-label` or `aria-labelledby` names
212
+ * nothing, so it counts as missing.
213
+ * `check-accessibility-tree.ts` checks the name a screen reader gets.
214
+ */
215
+ const labelledBy = this.getAttribute("aria-labelledby")?.trim() || null
216
+ const label = this.getAttribute("aria-label")?.trim() || null
217
+ const ours = labelledBy !== null && labelledBy === this.labelledByWritten
218
+ if (labelledBy !== null && !ours) this.labelledByWritten = null
219
+ if (title?.id && label === null && (labelledBy === null || ours)) {
220
+ setAttr(this, "aria-labelledby", title.id)
221
+ this.labelledByWritten = title.id
222
+ } else if (ours) {
223
+ // The heading is gone or lost its id, or the server named the box
224
+ // with `aria-label`, which `aria-labelledby` would otherwise win over.
225
+ this.removeAttribute("aria-labelledby")
226
+ this.labelledByWritten = null
227
+ }
228
+ if (title && !title.id && label === null && labelledBy === null)
229
+ warnAboutMarkup(
230
+ this,
231
+ "has a heading without an id and no name, so it takes focus as an alert a screen reader cannot name. Give the heading an id and the box aria-labelledby pointing at it, as lt.errorSummary({ id }) does.",
232
+ () => {
233
+ const now = this.querySelector(LT_ERROR_SUMMARY_PARTS.title)
234
+ return (
235
+ this.isConnected &&
236
+ now !== null &&
237
+ !now.id &&
238
+ !this.getAttribute("aria-label")?.trim() &&
239
+ !this.getAttribute("aria-labelledby")?.trim()
240
+ )
241
+ },
242
+ )
143
243
  }
144
244
 
145
245
  private sync(): void {
@@ -160,6 +260,15 @@ export class LtErrorSummary extends HostElement {
160
260
  if (this.hidden) {
161
261
  this.hasFocused = false
162
262
  this.focusAttempted = false
263
+ /*
264
+ * A hidden box gives up focus. HTML's focus fixup rule moves focus
265
+ * off an element that is no longer rendered; Chromium does it at
266
+ * once, Firefox and WebKit left it on the hidden box. idiomorph leaves
267
+ * the content of the focused element as it was, so the next errors
268
+ * the server sent never reached the box, which then showed an empty
269
+ * heading and list. Found by the model test.
270
+ */
271
+ this.blurInside()
163
272
  }
164
273
 
165
274
  if (links.length === 0) {
@@ -218,6 +327,11 @@ export class LtErrorSummary extends HostElement {
218
327
  const active = document.activeElement
219
328
  const nobodyTyping = !active || active === document.body
220
329
  if (this.focusAttempted && !nobodyTyping) return
330
+ const movedByScript =
331
+ this.focusBefore !== undefined &&
332
+ active !== this.focusBefore &&
333
+ !nobodyTyping
334
+ if (movedByScript) return
221
335
  const root = this.getRootNode() as Document | ShadowRoot
222
336
 
223
337
  this.focusAttempted = true
@@ -4,9 +4,13 @@ import {
4
4
  addClass,
5
5
  defineElement,
6
6
  HostElement,
7
+ markWritten,
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
  import { computeFieldAttributes } from "./field-core.js"
@@ -47,6 +51,9 @@ function readMarker(value: string | null | undefined): Marker | undefined {
47
51
  * back.
48
52
  */
49
53
  const DERIVED_ATTRIBUTES = [
54
+ // A morph from the server removes the mark, and the component puts it
55
+ // back: the same markup must give the same page.
56
+ WRITTEN,
50
57
  "class",
51
58
  "for",
52
59
  "id",
@@ -325,35 +332,42 @@ export class LtField extends HostElement {
325
332
  const known = this.labelMemory.get(label)
326
333
  if (!known) return now
327
334
 
335
+ const spoke = (name: string, changed: boolean) =>
336
+ changed || !stillMarked(label, name)
328
337
  return {
329
- required:
330
- now.required !== known.written.required
331
- ? now.required
332
- : known.server.required,
333
- optional:
334
- now.optional !== known.written.optional
335
- ? now.optional
336
- : known.server.optional,
338
+ required: spoke("data-required", now.required !== known.written.required)
339
+ ? now.required
340
+ : known.server.required,
341
+ optional: spoke("data-optional", now.optional !== known.written.optional)
342
+ ? now.optional
343
+ : known.server.optional,
337
344
  }
338
345
  }
339
346
 
340
347
  private serverWord(control: Element, now: ControlWord): ControlWord {
341
348
  const known = this.memory.get(control)
342
- if (!known) return now
349
+ if (!known) {
350
+ warnAboutCopiedMark(this, control)
351
+ return now
352
+ }
343
353
 
354
+ // The server's when the value is another, or when a morph from the
355
+ // server has been there since the component wrote it (`WRITTEN`).
356
+ const spoke = (name: string, changed: boolean) =>
357
+ changed || !stillMarked(control, name)
344
358
  return {
345
- invalid:
346
- now.invalid !== known.written.invalid
347
- ? now.invalid
348
- : known.server.invalid,
349
- describedBy:
350
- now.describedBy !== known.written.describedBy
351
- ? now.describedBy
352
- : known.server.describedBy,
353
- disabled:
354
- now.disabled !== known.written.disabled
355
- ? now.disabled
356
- : known.server.disabled,
359
+ invalid: spoke("aria-invalid", now.invalid !== known.written.invalid)
360
+ ? now.invalid
361
+ : known.server.invalid,
362
+ describedBy: spoke(
363
+ "aria-describedby",
364
+ now.describedBy !== known.written.describedBy,
365
+ )
366
+ ? now.describedBy
367
+ : known.server.describedBy,
368
+ disabled: spoke("disabled", now.disabled !== known.written.disabled)
369
+ ? now.disabled
370
+ : known.server.disabled,
357
371
  }
358
372
  }
359
373
 
@@ -683,11 +697,22 @@ export class LtField extends HostElement {
683
697
  }
684
698
  setAttr(label, "data-required", computed.label["data-required"])
685
699
  setAttr(label, "data-optional", computed.label["data-optional"])
700
+ const writtenLabel: LabelWord = {
701
+ required: computed.label["data-required"] ?? null,
702
+ optional: computed.label["data-optional"] !== undefined,
703
+ }
704
+ markWritten(
705
+ label,
706
+ "data-required",
707
+ writtenLabel.required !== serverLabel.required,
708
+ )
709
+ markWritten(
710
+ label,
711
+ "data-optional",
712
+ writtenLabel.optional !== serverLabel.optional,
713
+ )
686
714
  this.labelMemory.set(label, {
687
- written: {
688
- required: computed.label["data-required"] ?? null,
689
- optional: computed.label["data-optional"] !== undefined,
690
- },
715
+ written: writtenLabel,
691
716
  server: serverLabel,
692
717
  })
693
718
  setAttr(label, "aria-disabled", computed.label["aria-disabled"])
@@ -714,12 +739,24 @@ export class LtField extends HostElement {
714
739
  // alongside it: a real `disabled` is already visible to assistive
715
740
  // technology, and `computeFieldAttributes` only writes it on the label.
716
741
  setFlag(control, "disabled", disabled)
742
+ const written: ControlWord = {
743
+ invalid: ariaInvalid,
744
+ describedBy: computed.control["aria-describedby"] ?? null,
745
+ disabled,
746
+ }
747
+ markWritten(control, "aria-invalid", written.invalid !== server.invalid)
748
+ markWritten(
749
+ control,
750
+ "aria-describedby",
751
+ written.describedBy !== server.describedBy,
752
+ )
753
+ markWritten(control, "disabled", written.disabled !== server.disabled)
717
754
 
718
755
  this.memory.set(control, {
719
756
  written: {
720
- invalid: ariaInvalid,
721
- describedBy: computed.control["aria-describedby"] ?? null,
722
- disabled,
757
+ invalid: written.invalid,
758
+ describedBy: written.describedBy,
759
+ disabled: written.disabled,
723
760
  },
724
761
  server,
725
762
  })
@@ -196,7 +196,8 @@ export class LtPopover extends HostElement {
196
196
  * user or the server has said so. If it is gone while the user opened it, the
197
197
  * morph took it, and then it comes back.
198
198
  */
199
- if (this.wantsOpen && !this.open && !isServerControlled(this)) {
199
+ const repairing = this.wantsOpen && !this.open && !isServerControlled(this)
200
+ if (repairing) {
200
201
  /*
201
202
  * Wait until the whole patch has landed before opening the popover again.
202
203
  *
@@ -206,10 +207,16 @@ export class LtPopover extends HostElement {
206
207
  * was taking over the state, and the popover stayed open afterwards.
207
208
  * `queueMicrotask` runs after the whole patch is done, and the condition is
208
209
  * checked again there.
210
+ *
211
+ * Until then the panel stays as it is: closing it while the repair waits
212
+ * took the focus from a button in it to the trigger on every patch.
213
+ * When the repair is called off, `sync()` closes the panel for real.
209
214
  */
210
215
  queueMicrotask(() => {
211
- if (!this.isConnected || isServerControlled(this)) return
212
- if (this.wantsOpen && !this.open) setFlag(this, "open", true)
216
+ if (!this.isConnected) return
217
+ if (!isServerControlled(this) && this.wantsOpen && !this.open)
218
+ setFlag(this, "open", true)
219
+ else this.sync()
213
220
  })
214
221
  }
215
222
 
@@ -322,16 +329,30 @@ export class LtPopover extends HostElement {
322
329
  this.panel = panel
323
330
  }
324
331
 
332
+ // Shown while a repair waits, so the button says the same as the panel.
333
+ const shown = this.open || repairing
334
+
325
335
  // Written by the component, and set again if a patch took it.
326
- setAttr(trigger, "aria-expanded", String(this.open))
336
+ setAttr(trigger, "aria-expanded", String(shown))
327
337
 
328
- if (this.open) {
338
+ if (shown) {
329
339
  if (!panel.matches(":popover-open")) panel.showPopover()
330
340
  this.reposition()
331
341
  document.addEventListener("click", this.handleOutsideClick, true)
332
342
  document.addEventListener("keydown", this.handleKeydown)
333
343
  } else {
334
- if (panel.matches(":popover-open")) panel.hidePopover()
344
+ if (panel.matches(":popover-open")) {
345
+ /*
346
+ * Focus in the panel goes back to the button, whatever closed it:
347
+ * `hide()`, `toggle()`, the server taking `open`. Otherwise it stays
348
+ * on a button that is no longer shown, and the next key press starts
349
+ * at the top of the page. Escape did this alone before. Focus
350
+ * elsewhere, such as a field the user clicked outside, stays.
351
+ */
352
+ const inside = panel.contains(document.activeElement)
353
+ panel.hidePopover()
354
+ if (inside) trigger.focus()
355
+ }
335
356
  document.removeEventListener("click", this.handleOutsideClick, true)
336
357
  document.removeEventListener("keydown", this.handleKeydown)
337
358
  }
@@ -397,7 +418,13 @@ export class LtPopover extends HostElement {
397
418
  }
398
419
 
399
420
  private handleTriggerClick = (): void => {
421
+ const closing = this.open
400
422
  this.toggle()
423
+ // Safari gives a clicked button no focus: focus in the panel went to
424
+ // `<body>` with the click, before the panel closed, and `sync()` did not
425
+ // see it there. The other browsers have given the button focus already.
426
+ if (closing && document.activeElement === document.body)
427
+ this.triggerElement?.focus()
401
428
  }
402
429
 
403
430
  private handlePanelToggle = (event: Event): void => {
@@ -529,18 +556,22 @@ export class LtPopover extends HostElement {
529
556
  )
530
557
  }
531
558
 
532
- /** Opens the panel. */
559
+ /**
560
+ * Opens the panel. Through the setter also when it is open already, as
561
+ * when the server sent `open`: the call is the app's message, and without
562
+ * it the next patch without `open` closed the panel.
563
+ */
533
564
  show(): void {
534
- if (this.open) return
565
+ const was = this.open
535
566
  this.open = true
536
- this.emit(true)
567
+ if (!was) this.emit(true)
537
568
  }
538
569
 
539
- /** Closes the panel. */
570
+ /** Closes the panel. Through the setter also when it is closed, as `show()`. */
540
571
  hide(): void {
541
- if (!this.open) return
572
+ const was = this.open
542
573
  this.open = false
543
- this.emit(false)
574
+ if (was) this.emit(false)
544
575
  }
545
576
 
546
577
  /** Opens or closes. */
@@ -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 {