@lo-tek/interfaces 0.42.0 → 0.43.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 (62) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/agent/astro.md +1 -1
  3. package/agent/bundles.md +1 -1
  4. package/agent/datastar.md +50 -50
  5. package/agent/html.md +53 -53
  6. package/agent/kotlin.md +4 -4
  7. package/agent/react.md +1 -1
  8. package/agent/templates.md +50 -50
  9. package/dist/components/frame/dialog/lt-dialog.d.ts +11 -3
  10. package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -1
  11. package/dist/components/frame/dialog/lt-dialog.js +59 -46
  12. package/dist/components/frame/dialog/lt-dialog.js.map +1 -1
  13. package/dist/components/frame/error-summary/lt-error-summary.d.ts +9 -6
  14. package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -1
  15. package/dist/components/frame/error-summary/lt-error-summary.js +38 -57
  16. package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -1
  17. package/dist/components/frame/field/lt-field.d.ts +17 -26
  18. package/dist/components/frame/field/lt-field.d.ts.map +1 -1
  19. package/dist/components/frame/field/lt-field.js +81 -143
  20. package/dist/components/frame/field/lt-field.js.map +1 -1
  21. package/dist/components/frame/popover/lt-popover.d.ts +1 -1
  22. package/dist/components/frame/popover/lt-popover.d.ts.map +1 -1
  23. package/dist/components/frame/popover/lt-popover.js +31 -33
  24. package/dist/components/frame/popover/lt-popover.js.map +1 -1
  25. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts +14 -2
  26. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -1
  27. package/dist/components/frame/session-timeout/lt-session-timeout.js +73 -46
  28. package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -1
  29. package/dist/components/frame/suggestion/lt-suggestion.d.ts +5 -7
  30. package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -1
  31. package/dist/components/frame/suggestion/lt-suggestion.js +57 -72
  32. package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -1
  33. package/dist/components/frame/tabs/lt-tabs.d.ts +14 -5
  34. package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -1
  35. package/dist/components/frame/tabs/lt-tabs.js +86 -106
  36. package/dist/components/frame/tabs/lt-tabs.js.map +1 -1
  37. package/dist/components/frame/term/lt-term.d.ts +5 -1
  38. package/dist/components/frame/term/lt-term.d.ts.map +1 -1
  39. package/dist/components/frame/term/lt-term.js +23 -21
  40. package/dist/components/frame/term/lt-term.js.map +1 -1
  41. package/dist/components/host-element.d.ts +137 -2
  42. package/dist/components/host-element.d.ts.map +1 -1
  43. package/dist/components/host-element.js +187 -4
  44. package/dist/components/host-element.js.map +1 -1
  45. package/dist/lotek.css +1 -1
  46. package/dist/react.d.ts +51 -437
  47. package/dist/react.d.ts.map +1 -1
  48. package/dist/react.js +48 -208
  49. package/dist/react.js.map +1 -1
  50. package/manifest/manifest.json +1 -1
  51. package/package.json +7 -3
  52. package/src/components/frame/dialog/lt-dialog.ts +56 -50
  53. package/src/components/frame/error-summary/lt-error-summary.ts +33 -46
  54. package/src/components/frame/field/lt-field.ts +99 -207
  55. package/src/components/frame/popover/lt-popover.ts +37 -35
  56. package/src/components/frame/session-timeout/lt-session-timeout.ts +88 -46
  57. package/src/components/frame/suggestion/lt-suggestion.ts +59 -73
  58. package/src/components/frame/tabs/lt-tabs.ts +96 -122
  59. package/src/components/frame/term/lt-term.ts +27 -21
  60. package/src/components/host-element.ts +306 -3
  61. package/src/react.ts +125 -240
  62. package/web-types.json +1 -1
@@ -2,8 +2,12 @@ import {
2
2
  addClass,
3
3
  defineElement,
4
4
  HostElement,
5
+ hasName,
6
+ nameFromHeading,
7
+ ownership,
5
8
  setAttr,
6
9
  warnAboutMarkup,
10
+ watcher,
7
11
  } from "../../host-element.js"
8
12
  import {
9
13
  ERROR_SUMMARY_CLASS,
@@ -82,28 +86,18 @@ export class LtErrorSummary extends HostElement {
82
86
  private focusAttempted = false
83
87
  /** Where focus was when `hidden` last changed, until the deferred round has run. */
84
88
  private focusBefore: Element | null | undefined
85
- private observer?: MutationObserver
86
89
  /**
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.
90
+ * The `aria-labelledby` the component wrote on the box, through
91
+ * `nameFromHeading()`.
91
92
  */
92
- private labelledByWritten: string | null = null
93
-
94
- connectedCallback(): void {
95
- // One listener on the host, not one per link. The set of links was only
96
- // emptied on disconnect, so every link a patch replaced stayed in
97
- // memory.
98
- this.addEventListener("click", this.handleLinkClick)
99
- // The errors come and go while the user corrects them, and `slotchange`
100
- // does not fire in plain DOM. The attributes are the ones the component
101
- // fills in itself, so a patch that removes them gets them back. Every
102
- // write compares first, or the observer would trigger itself.
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) => {
93
+ private readonly own = ownership()
94
+ /**
95
+ * An id only matters on the heading, which the name points at, and a name
96
+ * only on the box. The same attributes on a link or a field inside the box
97
+ * are not ours to follow.
98
+ */
99
+ private readonly watch = watcher(
100
+ (records) => {
107
101
  const relevant = records.some((r) => {
108
102
  if (r.attributeName === "id")
109
103
  return (r.target as Element).matches(LT_ERROR_SUMMARY_PARTS.title)
@@ -115,8 +109,8 @@ export class LtErrorSummary extends HostElement {
115
109
  return true
116
110
  })
117
111
  if (relevant) this.sync()
118
- })
119
- this.observer.observe(this, {
112
+ },
113
+ {
120
114
  childList: true,
121
115
  subtree: true,
122
116
  attributes: true,
@@ -128,7 +122,19 @@ export class LtErrorSummary extends HostElement {
128
122
  "aria-label",
129
123
  "id",
130
124
  ],
131
- })
125
+ },
126
+ )
127
+
128
+ connectedCallback(): void {
129
+ // One listener on the host, not one per link. The set of links was only
130
+ // emptied on disconnect, so every link a patch replaced stayed in
131
+ // memory.
132
+ this.addEventListener("click", this.handleLinkClick)
133
+ // The errors come and go while the user corrects them, and `slotchange`
134
+ // does not fire in plain DOM. The attributes are the ones the component
135
+ // fills in itself, so a patch that removes them gets them back. Every
136
+ // write compares first, or the observer would trigger itself.
137
+ this.watch.start(this)
132
138
  this.sync()
133
139
  }
134
140
 
@@ -164,8 +170,7 @@ export class LtErrorSummary extends HostElement {
164
170
  }
165
171
 
166
172
  disconnectedCallback(): void {
167
- this.observer?.disconnect()
168
- this.observer = undefined
173
+ this.watch.stop()
169
174
  this.removeEventListener("click", this.handleLinkClick)
170
175
  }
171
176
 
@@ -212,32 +217,14 @@ export class LtErrorSummary extends HostElement {
212
217
  * nothing, so it counts as missing.
213
218
  * `check-accessibility-tree.ts` checks the name a screen reader gets.
214
219
  */
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)
220
+ const name = nameFromHeading(this.own, this, this, () => title?.id || null)
221
+ if (name === "none" && title && !title.id)
229
222
  warnAboutMarkup(
230
223
  this,
231
224
  "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
225
  () => {
233
226
  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
- )
227
+ return this.isConnected && now !== null && !now.id && !hasName(this)
241
228
  },
242
229
  )
243
230
  }
@@ -4,14 +4,14 @@ import {
4
4
  addClass,
5
5
  defineElement,
6
6
  HostElement,
7
- markWritten,
7
+ ownership,
8
8
  setAttr,
9
9
  setFlag,
10
- stillMarked,
11
10
  uniqueId,
12
11
  WRITTEN,
13
12
  warnAboutCopiedMark,
14
13
  warnAboutMarkup,
14
+ watcher,
15
15
  } from "../../host-element.js"
16
16
  import { computeFieldAttributes } from "./field-core.js"
17
17
 
@@ -69,58 +69,6 @@ const DERIVED_ATTRIBUTES = [
69
69
  "style",
70
70
  ]
71
71
 
72
- /**
73
- * What the component last wrote on a control, and what the server last said.
74
- *
75
- * Three attributes are both read from the control and written back to it:
76
- * `aria-invalid`, `aria-describedby` and `disabled`. They are read because
77
- * the server may have written the field with `lt.field()`, and then the
78
- * answer is already there. But a naive read is the component's echo from the
79
- * previous round. For `aria-invalid` that meant `field.invalid = false`
80
- * removed the flag on the host while the red border stayed for good. For
81
- * `aria-describedby` the list never shrank: the error text's id stayed after
82
- * the error was gone. For `disabled` the field could not be enabled again
83
- * from the host.
84
- *
85
- * The fix is not to remember what the host said last. That was tried, and
86
- * made the component depend on its history: the same markup gave two
87
- * different answers depending on whether the host had had `invalid` at some
88
- * point.
89
- *
90
- * Instead the component notes the value it wrote, and on which control. If
91
- * something else is there next time, someone else has touched the
92
- * attribute, and that is the server's word. The read is therefore always
93
- * derived from a change that actually happened, and the same document always
94
- * gives the same answer.
95
- */
96
- type ControlMemory = {
97
- written: ControlWord
98
- server: ControlWord
99
- }
100
-
101
- /**
102
- * The same memory for the markers on the label. The component both reads and
103
- * writes `data-required` and `data-optional`, and without knowing what it
104
- * wrote itself, it read its own answer back: `optional` and the required
105
- * marker could not be turned off again.
106
- */
107
- type LabelWord = { required: string | null; optional: boolean }
108
- type LabelMemory = { written: LabelWord; server: LabelWord }
109
-
110
- type ControlWord = {
111
- invalid: string | null
112
- describedBy: string | null
113
- disabled: boolean
114
- }
115
-
116
- function readControlWord(control: Element): ControlWord {
117
- return {
118
- invalid: control.getAttribute("aria-invalid"),
119
- describedBy: control.getAttribute("aria-describedby"),
120
- disabled: control.hasAttribute("disabled"),
121
- }
122
- }
123
-
124
72
  /**
125
73
  * Connects label, control, help text and error text in plain DOM.
126
74
  *
@@ -153,7 +101,31 @@ function readControlWord(control: Element): ControlWord {
153
101
  export class LtField extends HostElement {
154
102
  static observedAttributes = ["disabled", "optional", "described-by"] as const
155
103
 
156
- private observer?: MutationObserver
104
+ /**
105
+ * The children and the derived attributes, watched while connected.
106
+ * `style` is observed for `display: none` on the error text; a style write
107
+ * elsewhere in the field, such as a popover positioning its panel, does not
108
+ * concern the wiring.
109
+ */
110
+ private readonly watch = watcher(
111
+ (records) => {
112
+ const onlyForeignStyle = records.every(
113
+ (r) =>
114
+ r.type === "attributes" &&
115
+ r.attributeName === "style" &&
116
+ !(r.target as Element).matches?.(LT_FIELD_PARTS.error),
117
+ )
118
+ if (!onlyForeignStyle) this.sync()
119
+ },
120
+ {
121
+ childList: true,
122
+ subtree: true,
123
+ attributes: true,
124
+ attributeFilter: DERIVED_ATTRIBUTES,
125
+ // A morph can change the text of the error text without replacing the node.
126
+ characterData: true,
127
+ },
128
+ )
157
129
  /**
158
130
  * The ids the help text and the error text last had, whether they came
159
131
  * from the markup or from here, so a patch that removes them gets the same
@@ -165,20 +137,17 @@ export class LtField extends HostElement {
165
137
  private lastErrorId?: string
166
138
  private readonly managedIds = new Set<string>()
167
139
  /**
168
- * What the component wrote on each control, keyed on the control itself.
169
- *
170
- * A `WeakMap` and not a "previous control" property. The memory has to
171
- * survive the host being disconnected and connected again, as when React
172
- * moves a field, and the control disappearing and coming back in a patch.
173
- * A property that was reset in `disconnectedCallback` made the component
174
- * read its own `aria-invalid="true"` as the server's word after a move, and
175
- * the field could never become valid again. A `WeakMap` also keeps no
176
- * detached node alive: when the control is gone for good, so is the memory.
140
+ * What the component wrote on the control and the label, and the server's
141
+ * word it wrote against, per node. The memory has to survive the host
142
+ * being disconnected and connected again, as when React moves a field,
143
+ * and the control disappearing and coming back in a patch: a property
144
+ * reset in `disconnectedCallback` made the component read its own
145
+ * `aria-invalid="true"` as the server's word after a move, and the field
146
+ * could never become valid again. The component both reads and writes
147
+ * `data-required` and `data-optional` on the label too, and without the
148
+ * memory `optional` and the required marker could not be turned off.
177
149
  */
178
- private readonly memory = new WeakMap<Element, ControlMemory>()
179
- private readonly labelMemory = new WeakMap<Element, LabelMemory>()
180
- /** The `for` the component itself wrote on the label. */
181
- private readonly forByMe = new WeakMap<Element, string>()
150
+ private readonly own = ownership()
182
151
  /**
183
152
  * Labels where the server wrote a `for` pointing to a different id than
184
153
  * the control's. The component rewrites it, but without JavaScript the
@@ -202,8 +171,6 @@ export class LtField extends HostElement {
202
171
  * detached node alive until the next `sync()`, on purpose.
203
172
  */
204
173
  private lastControl?: HTMLElement
205
- /** The controls where `data-state` was written by the component. */
206
- private readonly stateByMe = new WeakSet<Element>()
207
174
 
208
175
  /**
209
176
  * The properties mirror the attributes.
@@ -253,32 +220,12 @@ export class LtField extends HostElement {
253
220
  * Every write in `sync()` compares first. Without that the observer
254
221
  * would trigger itself forever.
255
222
  */
256
- this.observer = new MutationObserver((records) => {
257
- // `style` is observed for `display: none` on the error text. A style
258
- // write elsewhere in the field, such as a popover positioning its
259
- // panel, does not concern the wiring.
260
- const onlyForeignStyle = records.every(
261
- (r) =>
262
- r.type === "attributes" &&
263
- r.attributeName === "style" &&
264
- !(r.target as Element).matches?.(LT_FIELD_PARTS.error),
265
- )
266
- if (!onlyForeignStyle) this.sync()
267
- })
268
- this.observer.observe(this, {
269
- childList: true,
270
- subtree: true,
271
- attributes: true,
272
- attributeFilter: DERIVED_ATTRIBUTES,
273
- // A morph can change the text of the error text without replacing the node.
274
- characterData: true,
275
- })
223
+ this.watch.start(this)
276
224
  this.sync()
277
225
  }
278
226
 
279
227
  disconnectedCallback(): void {
280
- this.observer?.disconnect()
281
- this.observer = undefined
228
+ this.watch.stop()
282
229
  }
283
230
 
284
231
  attributeChangedCallback(): void {
@@ -301,7 +248,7 @@ export class LtField extends HostElement {
301
248
  private noteFor(label: Element, id: string): void {
302
249
  const current = label.getAttribute("for")
303
250
  if (current === null || current === id) return
304
- if (current !== this.forByMe.get(label)) this.wrongFor.add(label)
251
+ if (current !== this.own.written(label, "for")) this.wrongFor.add(label)
305
252
  }
306
253
 
307
254
  private resolveLabel(control: HTMLElement | null): HTMLLabelElement | null {
@@ -316,62 +263,6 @@ export class LtField extends HostElement {
316
263
  )
317
264
  }
318
265
 
319
- /**
320
- * The server's word about the control, derived from what is there now
321
- * compared with what the component itself wrote last.
322
- *
323
- * A control the component has never written on holds only the server's
324
- * word. Otherwise each attribute is the server's if it differs from what
325
- * the component wrote, and unchanged if it is the same.
326
- */
327
- private serverLabelWord(label: Element | null): LabelWord {
328
- if (!label) return { required: null, optional: false }
329
- const now: LabelWord = {
330
- required: label.getAttribute("data-required"),
331
- optional: label.hasAttribute("data-optional"),
332
- }
333
- const known = this.labelMemory.get(label)
334
- if (!known) return now
335
-
336
- const spoke = (name: string, changed: boolean) =>
337
- changed || !stillMarked(label, name)
338
- return {
339
- required: spoke("data-required", now.required !== known.written.required)
340
- ? now.required
341
- : known.server.required,
342
- optional: spoke("data-optional", now.optional !== known.written.optional)
343
- ? now.optional
344
- : known.server.optional,
345
- }
346
- }
347
-
348
- private serverWord(control: Element, now: ControlWord): ControlWord {
349
- const known = this.memory.get(control)
350
- if (!known) {
351
- warnAboutCopiedMark(this, control)
352
- return now
353
- }
354
-
355
- // The server's when the value is another, or when a morph from the
356
- // server has been there since the component wrote it (`WRITTEN`).
357
- const spoke = (name: string, changed: boolean) =>
358
- changed || !stillMarked(control, name)
359
- return {
360
- invalid: spoke("aria-invalid", now.invalid !== known.written.invalid)
361
- ? now.invalid
362
- : known.server.invalid,
363
- describedBy: spoke(
364
- "aria-describedby",
365
- now.describedBy !== known.written.describedBy,
366
- )
367
- ? now.describedBy
368
- : known.server.describedBy,
369
- disabled: spoke("disabled", now.disabled !== known.written.disabled)
370
- ? now.disabled
371
- : known.server.disabled,
372
- }
373
- }
374
-
375
266
  /**
376
267
  * The parts that belong to this field, and not to a field further down.
377
268
  *
@@ -471,11 +362,8 @@ export class LtField extends HostElement {
471
362
  "found no control to connect. Add an <input>, <textarea> or " +
472
363
  "<select>, or the label, help text and error text have no field " +
473
364
  "to be connected to.",
474
- // An empty element is a region the server has not filled yet, and
475
- // that is not an error in the markup.
476
- () =>
477
- this.childElementCount > 0 &&
478
- this.ownParts(CONTROL_SELECTOR).length === 0,
365
+ () => this.ownParts(CONTROL_SELECTOR).length === 0,
366
+ { silentWhenEmpty: true },
479
367
  )
480
368
  return
481
369
  }
@@ -604,16 +492,33 @@ export class LtField extends HostElement {
604
492
  // looked at its own attributes would remove them again. In React that
605
493
  // gave a hydration error: the server sent `data-required="symbol"`, the
606
494
  // component removed it, and then React decided the HTML did not match.
607
- const serverLabel = this.serverLabelWord(label)
495
+ const serverLabel = {
496
+ required: label ? this.own.serverWord(label, "data-required") : null,
497
+ optional: label
498
+ ? this.own.serverWord(label, "data-optional", { presence: true }) !==
499
+ null
500
+ : false,
501
+ }
608
502
  const marker = readMarker(serverLabel.required)
609
503
 
610
504
  // The state is read from the markup, not just from an attribute on the
611
505
  // host. If the server wrote the field with `lt.field()`, the answer is
612
506
  // already on the control, and a component that computed its own would
613
- // remove it again. The two halves of the API then fought each other. See
614
- // `ControlMemory` for how the server's word is told apart from what the
615
- // component wrote.
616
- const server = this.serverWord(control, readControlWord(control))
507
+ // remove it again. The two halves of the API then fought each other.
508
+ // `ownership()` in host-element.ts tells the server's word apart from
509
+ // what the component wrote: by the value and the node it wrote it on,
510
+ // never by what the host said last, which gave two answers for the same
511
+ // markup depending on what the host had been through.
512
+ // A control the component has never written on holds only the server's
513
+ // word, and a mark found there was copied into the template.
514
+ if (!this.own.knows(control, "aria-invalid"))
515
+ warnAboutCopiedMark(this, control)
516
+ const server = {
517
+ invalid: this.own.serverWord(control, "aria-invalid"),
518
+ describedBy: this.own.serverWord(control, "aria-describedby"),
519
+ disabled:
520
+ this.own.serverWord(control, "disabled", { flag: true }) !== null,
521
+ }
617
522
 
618
523
  // `aria-invalid` has four valid values, and both `grammar` and
619
524
  // `spelling` mean invalid. Absence, `false` and the empty string mean
@@ -693,29 +598,24 @@ export class LtField extends HostElement {
693
598
  // the wiring is implicit.
694
599
  if (control.id && !label.contains(control)) {
695
600
  this.noteFor(label, control.id)
696
- setAttr(label, "for", control.id)
697
- this.forByMe.set(label, control.id)
698
- }
699
- setAttr(label, "data-required", computed.label["data-required"])
700
- setAttr(label, "data-optional", computed.label["data-optional"])
701
- const writtenLabel: LabelWord = {
702
- required: computed.label["data-required"] ?? null,
703
- optional: computed.label["data-optional"] !== undefined,
601
+ this.own.write(label, "for", control.id)
704
602
  }
705
- markWritten(
603
+ this.own.write(
706
604
  label,
707
605
  "data-required",
708
- writtenLabel.required !== serverLabel.required,
606
+ computed.label["data-required"] ?? null,
607
+ { server: serverLabel.required, mark: true },
709
608
  )
710
- markWritten(
609
+ this.own.write(
711
610
  label,
712
611
  "data-optional",
713
- writtenLabel.optional !== serverLabel.optional,
612
+ computed.label["data-optional"] ?? null,
613
+ {
614
+ server: serverLabel.optional ? "" : null,
615
+ mark: true,
616
+ presence: true,
617
+ },
714
618
  )
715
- this.labelMemory.set(label, {
716
- written: writtenLabel,
717
- server: serverLabel,
718
- })
719
619
  setAttr(label, "aria-disabled", computed.label["aria-disabled"])
720
620
  }
721
621
 
@@ -730,8 +630,16 @@ export class LtField extends HostElement {
730
630
  ? "true"
731
631
  : server.invalid
732
632
 
733
- setAttr(control, "aria-describedby", computed.control["aria-describedby"])
734
- setAttr(control, "aria-invalid", ariaInvalid)
633
+ this.own.write(
634
+ control,
635
+ "aria-describedby",
636
+ computed.control["aria-describedby"] ?? null,
637
+ { server: server.describedBy, mark: true },
638
+ )
639
+ this.own.write(control, "aria-invalid", ariaInvalid, {
640
+ server: server.invalid,
641
+ mark: true,
642
+ })
735
643
  // `setFlag` and not `setAttr`: a template may have written
736
644
  // `disabled="disabled"`, and it must stay as it is. `setAttr` would
737
645
  // normalise the value to the empty string, and since `disabled` is among
@@ -739,33 +647,18 @@ export class LtField extends HostElement {
739
647
  // would each write their own value on every patch. No `aria-disabled`
740
648
  // alongside it: a real `disabled` is already visible to assistive
741
649
  // technology, and `computeFieldAttributes` only writes it on the label.
742
- setFlag(control, "disabled", disabled)
743
- const written: ControlWord = {
744
- invalid: ariaInvalid,
745
- describedBy: computed.control["aria-describedby"] ?? null,
746
- disabled,
747
- }
748
- markWritten(control, "aria-invalid", written.invalid !== server.invalid)
749
- markWritten(
750
- control,
751
- "aria-describedby",
752
- written.describedBy !== server.describedBy,
753
- )
754
- markWritten(control, "disabled", written.disabled !== server.disabled)
755
-
756
- this.memory.set(control, {
757
- written: {
758
- invalid: written.invalid,
759
- describedBy: written.describedBy,
760
- disabled: written.disabled,
761
- },
762
- server,
650
+ this.own.write(control, "disabled", disabled ? "" : null, {
651
+ server: server.disabled ? "" : null,
652
+ mark: true,
653
+ flag: true,
763
654
  })
764
655
 
765
656
  // `data-state` is only set on the system's controls, and only when the
766
- // consumer has not set it. It is only removed when the component set it
767
- // and the field is no longer invalid. A `data-state` a template wrote by
768
- // hand stays as the template wrote it.
657
+ // consumer has not set it. It is only removed when the component set it,
658
+ // the field is no longer invalid, and the server has not sent the same
659
+ // value since: written with the mark, so a morph that sent it removes
660
+ // the mark. A `data-state` a template wrote by hand stays as the
661
+ // template wrote it.
769
662
  const isSystemField =
770
663
  control.classList.contains("lt-input") ||
771
664
  control.classList.contains("lt-textarea") ||
@@ -773,15 +666,14 @@ export class LtField extends HostElement {
773
666
 
774
667
  const state = computed.control["data-state"]
775
668
  if (state && isSystemField && !control.hasAttribute("data-state")) {
776
- setAttr(control, "data-state", state)
777
- this.stateByMe.add(control)
669
+ this.own.write(control, "data-state", state, { server: null, mark: true })
778
670
  } else if (
779
671
  !state &&
780
- this.stateByMe.has(control) &&
781
- control.getAttribute("data-state") === "invalid"
672
+ this.own.written(control, "data-state") != null &&
673
+ control.getAttribute("data-state") === "invalid" &&
674
+ this.own.serverWord(control, "data-state") === null
782
675
  ) {
783
- control.removeAttribute("data-state")
784
- this.stateByMe.delete(control)
676
+ this.own.release(control, "data-state")
785
677
  }
786
678
  }
787
679
  }
@@ -8,6 +8,7 @@ import {
8
8
  setAttr,
9
9
  setFlag,
10
10
  warnAboutMarkup,
11
+ watcher,
11
12
  } from "../../host-element.js"
12
13
  import {
13
14
  isPopoverPlacement,
@@ -80,7 +81,35 @@ export class LtPopover extends HostElement {
80
81
 
81
82
  private panel?: HTMLElement
82
83
  private triggerElement?: HTMLElement
83
- private observer?: MutationObserver
84
+ private readonly watch = watcher(
85
+ (records) => {
86
+ // `type` concerns only the button. A password field in the panel that
87
+ // switches between `password` and `text` must not run all of `sync()`.
88
+ const onlyOtherTypes = records.every(
89
+ (record) =>
90
+ record.attributeName === "type" &&
91
+ record.target !== this.triggerElement,
92
+ )
93
+ if (!onlyOtherTypes) this.sync()
94
+ },
95
+ {
96
+ childList: true,
97
+ subtree: true,
98
+ attributes: true,
99
+ attributeFilter: [
100
+ "aria-expanded",
101
+ "style",
102
+ // The wiring the component fills in when the markup came without it.
103
+ "popover",
104
+ "id",
105
+ "aria-controls",
106
+ "class",
107
+ // A patch from a template without `type` removes it, and then the
108
+ // button submits the form again.
109
+ "type",
110
+ ],
111
+ },
112
+ )
84
113
  /**
85
114
  * What the component was last asked for, through the `open` property.
86
115
  *
@@ -133,33 +162,7 @@ export class LtPopover extends HostElement {
133
162
  * out, so if a patch removes them, they must come back. Every write in
134
163
  * `sync()` compares first, or the observer would trigger itself.
135
164
  */
136
- this.observer = new MutationObserver((records) => {
137
- // `type` concerns only the button. A password field in the panel that
138
- // switches between `password` and `text` must not run all of `sync()`.
139
- const onlyOtherTypes = records.every(
140
- (record) =>
141
- record.attributeName === "type" &&
142
- record.target !== this.triggerElement,
143
- )
144
- if (!onlyOtherTypes) this.sync()
145
- })
146
- this.observer.observe(this, {
147
- childList: true,
148
- subtree: true,
149
- attributes: true,
150
- attributeFilter: [
151
- "aria-expanded",
152
- "style",
153
- // The wiring the component fills in when the markup came without it.
154
- "popover",
155
- "id",
156
- "aria-controls",
157
- "class",
158
- // A patch from a template without `type` removes it, and then the
159
- // button submits the form again.
160
- "type",
161
- ],
162
- })
165
+ this.watch.start(this)
163
166
  this.sync()
164
167
  }
165
168
 
@@ -168,8 +171,7 @@ export class LtPopover extends HostElement {
168
171
  window.removeEventListener("scroll", this.reposition, true)
169
172
  document.removeEventListener("click", this.handleOutsideClick, true)
170
173
  document.removeEventListener("keydown", this.handleKeydown)
171
- this.observer?.disconnect()
172
- this.observer = undefined
174
+ this.watch.stop()
173
175
  this.triggerElement?.removeEventListener("click", this.handleTriggerClick)
174
176
  this.panel?.removeEventListener("toggle", this.handlePanelToggle)
175
177
  // The reference must be reset, or `sync()` sees that the button is the
@@ -269,16 +271,15 @@ export class LtPopover extends HostElement {
269
271
  /*
270
272
  * Two different faults, and a message for each, so the developer looks in
271
273
  * the right place. An empty element is a region the server has not filled
272
- * yet, and that is not a fault in the markup.
274
+ * yet, and that is not a fault in the markup: `silentWhenEmpty`.
273
275
  */
274
- const isEmpty = () => this.childElementCount === 0
275
-
276
276
  if (!panel) {
277
277
  warnAboutMarkup(
278
278
  this,
279
279
  "found no panel: an element with popover or the class " +
280
280
  "lt-popover. Without one there is nothing to open or position.",
281
- () => !isEmpty() && this.findPanel() === null,
281
+ () => this.findPanel() === null,
282
+ { silentWhenEmpty: true },
282
283
  )
283
284
  } else {
284
285
  warnAboutMarkup(
@@ -288,8 +289,9 @@ export class LtPopover extends HostElement {
288
289
  "component does not know what opens the popover.",
289
290
  () => {
290
291
  const found = this.findPanel()
291
- return !isEmpty() && found !== null && !this.findTrigger(found)
292
+ return found !== null && !this.findTrigger(found)
292
293
  },
294
+ { silentWhenEmpty: true },
293
295
  )
294
296
  }
295
297