@lo-tek/interfaces 0.41.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 (63) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/agent/astro.md +1 -1
  3. package/agent/bundles.md +1 -1
  4. package/agent/datastar.md +57 -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/live-templates-kotlin.xml +4 -6
  51. package/manifest/manifest.json +1 -1
  52. package/package.json +7 -3
  53. package/src/components/frame/dialog/lt-dialog.ts +56 -50
  54. package/src/components/frame/error-summary/lt-error-summary.ts +33 -46
  55. package/src/components/frame/field/lt-field.ts +99 -207
  56. package/src/components/frame/popover/lt-popover.ts +37 -35
  57. package/src/components/frame/session-timeout/lt-session-timeout.ts +88 -46
  58. package/src/components/frame/suggestion/lt-suggestion.ts +59 -73
  59. package/src/components/frame/tabs/lt-tabs.ts +96 -122
  60. package/src/components/frame/term/lt-term.ts +27 -21
  61. package/src/components/host-element.ts +306 -3
  62. package/src/react.ts +125 -240
  63. package/web-types.json +1 -1
@@ -2,11 +2,16 @@ import {
2
2
  addClass,
3
3
  defineElement,
4
4
  HostElement,
5
+ hasName,
5
6
  languageOf,
7
+ nameFromHeading,
8
+ ownership,
9
+ restoreModalOpen,
6
10
  setAttr,
7
11
  setText,
8
12
  uniqueId,
9
13
  warnAboutMarkup,
14
+ watcher,
10
15
  whenUpgraded,
11
16
  } from "../../host-element.js"
12
17
  import {
@@ -218,6 +223,13 @@ export class LtSessionTimeout extends HostElement {
218
223
  private expired = false
219
224
  /** The id the component gave the heading, so the same one comes back. */
220
225
  private titleId?: string
226
+ /**
227
+ * The `aria-labelledby` the component wrote on the dialog. Another value
228
+ * there is the renderer's, and stays; the component's own follows the
229
+ * heading, so a patch that replaces the heading does not leave the name
230
+ * pointing at an id that is gone.
231
+ */
232
+ private readonly own = ownership()
221
233
  /**
222
234
  * The dialog the component opened, until `close` has arrived.
223
235
  *
@@ -228,8 +240,47 @@ export class LtSessionTimeout extends HostElement {
228
240
  * `session-extend`.
229
241
  */
230
242
  private shownDialog?: HTMLDialogElement
231
- /** Watches for `open` disappearing from a dialog in the top layer. See `repairOpen()`. */
232
- private openObserver?: MutationObserver
243
+ /**
244
+ * Watches the subtree: `open` disappearing from a dialog in the top layer
245
+ * (see `repairOpen()`), and the role and the name the component wrote.
246
+ */
247
+ private readonly watch = watcher(
248
+ (records) => {
249
+ // The number is text, and a tick that writes it is no reason to look.
250
+ const replaced = records.some((record) =>
251
+ [...record.addedNodes, ...record.removedNodes].some(
252
+ (node) => node instanceof Element,
253
+ ),
254
+ )
255
+ const attributes = records.some((record) => record.type === "attributes")
256
+ if (!replaced && !attributes) return
257
+ this.repairOpen()
258
+ /*
259
+ * A morph of the open warning takes the role and the name the component
260
+ * wrote, since the renderer did not send them, and the screen reader had
261
+ * a modal dialog without a name until the warning closed. They are
262
+ * written again while it shows. Such a morph also removes `open`, which
263
+ * is enough to get here; the wiring's own attributes are watched for a
264
+ * morph that leaves `open` alone.
265
+ */
266
+ // A heading the renderer replaced, too.
267
+ const dialog = this.dialog
268
+ if (dialog?.matches(":modal")) this.wire(dialog)
269
+ },
270
+ {
271
+ subtree: true,
272
+ childList: true,
273
+ attributes: true,
274
+ attributeFilter: [
275
+ "open",
276
+ "role",
277
+ "class",
278
+ "id",
279
+ "aria-labelledby",
280
+ "aria-label",
281
+ ],
282
+ },
283
+ )
233
284
 
234
285
  /** Seconds without activity before the warning appears. */
235
286
  get warnAt(): number {
@@ -270,31 +321,7 @@ export class LtSessionTimeout extends HostElement {
270
321
  * dialog, without the component having to hold on to a particular node.
271
322
  */
272
323
  this.addEventListener("close", this.handleClose, true)
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
- })
286
- this.openObserver.observe(this, {
287
- subtree: true,
288
- attributes: true,
289
- attributeFilter: [
290
- "open",
291
- "role",
292
- "class",
293
- "id",
294
- "aria-labelledby",
295
- "aria-label",
296
- ],
297
- })
324
+ this.watch.start(this)
298
325
  this.ticker = window.setInterval(() => this.tick(), 1000)
299
326
  this.validate()
300
327
 
@@ -321,8 +348,7 @@ export class LtSessionTimeout extends HostElement {
321
348
  )
322
349
  }
323
350
  this.removeEventListener("close", this.handleClose, true)
324
- this.openObserver?.disconnect()
325
- this.openObserver = undefined
351
+ this.watch.stop()
326
352
  if (this.ticker) window.clearInterval(this.ticker)
327
353
  this.ticker = undefined
328
354
  }
@@ -380,15 +406,36 @@ export class LtSessionTimeout extends HostElement {
380
406
  this,
381
407
  "found no <dialog>. The warning cannot be shown, and the " +
382
408
  "session expires without warning.",
383
- () => this.childElementCount > 0 && this.dialog === null,
409
+ () => this.dialog === null,
410
+ { silentWhenEmpty: true },
384
411
  )
385
412
  this.validateParts()
386
413
  }
387
414
 
415
+ /** Says so when the dialog has no name, once the page has settled. */
416
+ private warnNameless(): void {
417
+ warnAboutMarkup(
418
+ this,
419
+ "the dialog has no name: it has no aria-labelledby, no aria-label " +
420
+ "and no heading, so a screen reader announces the warning " +
421
+ "without saying what it is about. Give it a heading, or " +
422
+ "aria-label on the <dialog>.",
423
+ () => {
424
+ const now = this.dialog
425
+ return (
426
+ now !== null &&
427
+ !hasName(now) &&
428
+ !now.querySelector(LT_SESSION_TIMEOUT_PARTS.title)
429
+ )
430
+ },
431
+ )
432
+ }
433
+
388
434
  /** The parts inside the dialog. Each of them makes the warning worse when missing. */
389
435
  private validateParts(): void {
390
436
  const dialog = this.dialog
391
437
  if (!dialog) return
438
+ this.warnNameless()
392
439
  warnAboutMarkup(
393
440
  this,
394
441
  `found no .${SESSION_TIMEOUT_COUNT_CLASS} in the dialog. The user cannot see how much time is left.`,
@@ -418,19 +465,16 @@ export class LtSessionTimeout extends HostElement {
418
465
  private wire(dialog: HTMLDialogElement): void {
419
466
  addClass(dialog, SESSION_TIMEOUT_DIALOG_CLASS)
420
467
  if (!dialog.hasAttribute("role")) setAttr(dialog, "role", "alertdialog")
421
- if (
422
- dialog.hasAttribute("aria-labelledby") ||
423
- dialog.hasAttribute("aria-label")
424
- ) {
425
- return
426
- }
427
- const title = dialog.querySelector(LT_SESSION_TIMEOUT_PARTS.title)
428
- if (!title) return
429
- if (!title.id) {
430
- this.titleId ??= uniqueId("lt-session-timeout-title")
431
- setAttr(title, "id", this.titleId)
432
- }
433
- setAttr(dialog, "aria-labelledby", title.id)
468
+ const name = nameFromHeading(this.own, this, dialog, () => {
469
+ const title = dialog.querySelector(LT_SESSION_TIMEOUT_PARTS.title)
470
+ if (!title) return null
471
+ if (!title.id) {
472
+ this.titleId ??= uniqueId("lt-session-timeout-title")
473
+ setAttr(title, "id", this.titleId)
474
+ }
475
+ return title.id
476
+ })
477
+ if (name === "none") this.warnNameless()
434
478
  }
435
479
 
436
480
  /**
@@ -446,9 +490,7 @@ export class LtSessionTimeout extends HostElement {
446
490
  * The same rule as in `<lt-dialog>`.
447
491
  */
448
492
  private repairOpen(): void {
449
- const dialog = this.dialog
450
- if (!dialog?.matches(":modal") || dialog.open) return
451
- setAttr(dialog, "open", "")
493
+ restoreModalOpen(this.dialog)
452
494
  }
453
495
 
454
496
  private registerActivity = (): void => {
@@ -7,12 +7,14 @@ import {
7
7
  HostElement,
8
8
  isServerControlled,
9
9
  languageOf,
10
+ ownership,
10
11
  rememberedId,
11
12
  SERVER_CONTROLLED,
12
13
  setAttr,
13
14
  setFlag,
14
15
  setText,
15
16
  warnAboutMarkup,
17
+ watcher,
16
18
  } from "../../host-element.js"
17
19
  import {
18
20
  SUGGESTION_EMPTY_CLASS,
@@ -119,7 +121,40 @@ export class LtSuggestion extends HostElement {
119
121
  ...COUNT_ATTRIBUTES,
120
122
  ] as const
121
123
 
122
- private observer?: MutationObserver
124
+ private readonly watch = watcher(() => this.sync(), {
125
+ childList: true,
126
+ subtree: true,
127
+ attributes: true,
128
+ attributeFilter: [
129
+ "hidden",
130
+ "aria-expanded",
131
+ "aria-selected",
132
+ "aria-activedescendant",
133
+ // The wiring the web component fills in when the markup arrived without it.
134
+ "role",
135
+ "id",
136
+ "for",
137
+ "aria-controls",
138
+ "autocomplete",
139
+ "aria-autocomplete",
140
+ "class",
141
+ // The value field's `value` is an attribute, and a morph can write
142
+ // the server's empty value over the code the user chose.
143
+ "value",
144
+ // `form` on the field picks which form the reset belongs to.
145
+ "form",
146
+ // A field that becomes `readonly` or `disabled` must close the list.
147
+ "readonly",
148
+ "disabled",
149
+ "aria-disabled",
150
+ // The list sits in the top layer. A morph that removes `popover`
151
+ // closes the popover, and the web component sets it back.
152
+ "popover",
153
+ // The list's name, from the label.
154
+ "aria-labelledby",
155
+ "aria-label",
156
+ ],
157
+ })
123
158
  private control?: HTMLInputElement
124
159
  private list?: HTMLElement
125
160
  private empty?: HTMLElement
@@ -128,7 +163,7 @@ export class LtSuggestion extends HostElement {
128
163
  /** The ids the web component created, per node, so a patch gets the same ones back. */
129
164
  private readonly ids = new WeakMap<Element, string>()
130
165
  /** The roles the web component set. See `derivedParts`. */
131
- private readonly roledByMe = new WeakSet<Element>()
166
+ private readonly roledByMe = ownership()
132
167
  /** True while the web component dispatches events, so it does not answer itself. */
133
168
  private choosing = false
134
169
  /**
@@ -155,17 +190,12 @@ export class LtSuggestion extends HostElement {
155
190
  private writtenCode?: string
156
191
  /** The form listened to for `reset`. Follows the field, which a patch can move. */
157
192
  private boundForm?: HTMLFormElement | null
158
- /** The `for` the web component wrote on the label. */
159
- private readonly forByMe = new WeakMap<Element, string>()
160
193
  /**
161
- * The name the web component gave the list, per list: `aria-labelledby`
162
- * pointing to the label, or `aria-label` with its text. If something else
163
- * is there, the server wrote it, and it stays.
194
+ * What the web component wrote on the label (`for`) and the list (its
195
+ * name: `aria-labelledby` pointing to the label, or `aria-label` with its
196
+ * text). Something else there is the server's, and stays.
164
197
  */
165
- private readonly listNameByMe = new WeakMap<
166
- Element,
167
- { name: "aria-labelledby" | "aria-label"; value: string }
168
- >()
198
+ private readonly own = ownership()
169
199
  /**
170
200
  * Labels where the server wrote a `for` pointing to another id. Reported
171
201
  * once; the memory is not reset, since the rewritten `for` cannot be told
@@ -214,41 +244,7 @@ export class LtSuggestion extends HostElement {
214
244
  * The attributes are included, not just the children. Every write below
215
245
  * compares first, or the observer would trigger itself forever.
216
246
  */
217
- this.observer = new MutationObserver(() => this.sync())
218
- this.observer.observe(this, {
219
- childList: true,
220
- subtree: true,
221
- attributes: true,
222
- attributeFilter: [
223
- "hidden",
224
- "aria-expanded",
225
- "aria-selected",
226
- "aria-activedescendant",
227
- // The wiring the web component fills in when the markup arrived without it.
228
- "role",
229
- "id",
230
- "for",
231
- "aria-controls",
232
- "autocomplete",
233
- "aria-autocomplete",
234
- "class",
235
- // The value field's `value` is an attribute, and a morph can write
236
- // the server's empty value over the code the user chose.
237
- "value",
238
- // `form` on the field picks which form the reset belongs to.
239
- "form",
240
- // A field that becomes `readonly` or `disabled` must close the list.
241
- "readonly",
242
- "disabled",
243
- "aria-disabled",
244
- // The list sits in the top layer. A morph that removes `popover`
245
- // closes the popover, and the web component sets it back.
246
- "popover",
247
- // The list's name, from the label.
248
- "aria-labelledby",
249
- "aria-label",
250
- ],
251
- })
247
+ this.watch.start(this)
252
248
  this.sync()
253
249
  window.addEventListener("resize", this.reposition)
254
250
  window.addEventListener("scroll", this.reposition, true)
@@ -269,10 +265,10 @@ export class LtSuggestion extends HostElement {
269
265
  /*
270
266
  * The attribute can come and go while the page is alive, and then the
271
267
  * list must be corrected at once rather than on the next keystroke.
272
- * `this.observer` signals that the web component is in the document: the
268
+ * `this.watch.watching` signals that the web component is in the document: the
273
269
  * callback also fires during the upgrade, before the children exist.
274
270
  */
275
- if (name === "prefiltered" && this.observer) {
271
+ if (name === "prefiltered" && this.watch.watching) {
276
272
  /*
277
273
  * After the whole update, as the other repairs: a morph sets the
278
274
  * attribute before it has replaced the options, and the count was
@@ -281,7 +277,7 @@ export class LtSuggestion extends HostElement {
281
277
  * match" to nobody's search.
282
278
  */
283
279
  queueMicrotask(() => {
284
- if (!this.observer) return
280
+ if (!this.watch.watching) return
285
281
  // The count present when the attribute is set is the starting point,
286
282
  // and nothing to announce. Without this the first change afterwards
287
283
  // was taken as the starting point, and was lost.
@@ -366,13 +362,12 @@ export class LtSuggestion extends HostElement {
366
362
  * gets the label's text as `aria-label`, without the field's value.
367
363
  */
368
364
  private nameList(list: HTMLElement, control: HTMLInputElement): void {
369
- const mine = this.listNameByMe.get(list)
370
365
  const labelledby = list.getAttribute("aria-labelledby")
371
366
  const label = list.getAttribute("aria-label")
372
367
  const fromServer =
373
368
  (labelledby !== null &&
374
- !(mine?.name === "aria-labelledby" && mine.value === labelledby)) ||
375
- (label !== null && !(mine?.name === "aria-label" && mine.value === label))
369
+ this.own.written(list, "aria-labelledby") !== labelledby) ||
370
+ (label !== null && this.own.written(list, "aria-label") !== label)
376
371
  if (fromServer) return
377
372
 
378
373
  const labelElement =
@@ -388,14 +383,13 @@ export class LtSuggestion extends HostElement {
388
383
  )
389
384
  text += node.textContent ?? ""
390
385
  text = text.replace(/\s+/g, " ").trim()
391
- if (labelledby !== null) setAttr(list, "aria-labelledby", null)
386
+ // One name at a time is the component's: writing one releases the other.
387
+ this.own.release(list, "aria-labelledby")
392
388
  if (text === "") {
393
- if (label !== null) setAttr(list, "aria-label", null)
394
- this.listNameByMe.delete(list)
389
+ this.own.release(list, "aria-label")
395
390
  return
396
391
  }
397
- setAttr(list, "aria-label", text)
398
- this.listNameByMe.set(list, { name: "aria-label", value: text })
392
+ this.own.write(list, "aria-label", text)
399
393
  return
400
394
  }
401
395
 
@@ -406,12 +400,8 @@ export class LtSuggestion extends HostElement {
406
400
  rememberedId(this.ids, labelElement, "lt-suggestion-label"),
407
401
  )
408
402
  }
409
- if (label !== null) setAttr(list, "aria-label", null)
410
- setAttr(list, "aria-labelledby", labelElement.id)
411
- this.listNameByMe.set(list, {
412
- name: "aria-labelledby",
413
- value: labelElement.id,
414
- })
403
+ this.own.release(list, "aria-label")
404
+ this.own.write(list, "aria-labelledby", labelElement.id)
415
405
  }
416
406
 
417
407
  /**
@@ -448,12 +438,11 @@ export class LtSuggestion extends HostElement {
448
438
  if (
449
439
  current !== null &&
450
440
  current !== control.id &&
451
- current !== this.forByMe.get(label)
441
+ current !== this.own.written(label, "for")
452
442
  ) {
453
443
  this.wrongFor.add(label)
454
444
  }
455
- setAttr(label, "for", control.id)
456
- this.forByMe.set(label, control.id)
445
+ this.own.write(label, "for", control.id)
457
446
  }
458
447
  warnAboutMarkup(
459
448
  this,
@@ -567,8 +556,7 @@ export class LtSuggestion extends HostElement {
567
556
  for (const option of this.options) {
568
557
  addClass(option, SUGGESTION_OPTION_CLASS)
569
558
  if (!option.hasAttribute("role")) {
570
- setAttr(option, "role", "option")
571
- this.roledByMe.add(option)
559
+ this.roledByMe.write(option, "role", "option")
572
560
  }
573
561
  if (!option.id) {
574
562
  setAttr(
@@ -697,8 +685,7 @@ export class LtSuggestion extends HostElement {
697
685
  window.removeEventListener("resize", this.reposition)
698
686
  window.removeEventListener("scroll", this.reposition, true)
699
687
  this.removeEventListener("focusout", this.handleFocusOut)
700
- this.observer?.disconnect()
701
- this.observer = undefined
688
+ this.watch.stop()
702
689
  this.unbind()
703
690
  }
704
691
 
@@ -859,9 +846,8 @@ export class LtSuggestion extends HostElement {
859
846
  "to, and neither filtering nor the arrow keys work.",
860
847
  // An empty element is a region the server has not filled yet, and not
861
848
  // an error in the markup.
862
- () =>
863
- this.childElementCount > 0 &&
864
- this.querySelector(CONTROL_SELECTOR) === null,
849
+ () => this.querySelector(CONTROL_SELECTOR) === null,
850
+ { silentWhenEmpty: true },
865
851
  )
866
852
  // The field is gone. Firefox sends no `focusout` when the focused node is
867
853
  // removed, so the list would otherwise stay open without a field.