@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
@@ -265,6 +265,295 @@ export function stillMarked(element: Element, name: string): boolean {
265
265
  return (element.getAttribute(WRITTEN) ?? "").split(/\s+/).includes(name)
266
266
  }
267
267
 
268
+ /**
269
+ * What a web component wrote on a node, and what the server had said there.
270
+ *
271
+ * Some attributes are both the server's and the component's, and the
272
+ * component must read the server's word back without taking its own echo
273
+ * for it. The question was answered in seven ways across the components,
274
+ * and the model tests found bugs in five classes there. This is the rule
275
+ * for them to share, and field uses it: per node and attribute, the value
276
+ * the component wrote and the server's word it wrote it against. A
277
+ * `WeakMap`, so the memory survives the host being moved, and a node that is
278
+ * gone for good takes its memory with it.
279
+ */
280
+ export interface Ownership {
281
+ /** Whether the component has written `name` on `element` before. */
282
+ knows(element: Element, name: string): boolean
283
+ /**
284
+ * The server's word for `name` on `element`: what stands there when the
285
+ * component has never written it, or when it differs from what the
286
+ * component wrote, or when a morph has removed the mark since; otherwise
287
+ * the server's word the component wrote against. With `presence` the
288
+ * attribute is compared by whether it is there, so `data-optional="true"`
289
+ * and `data-optional` are the same; a `flag` is compared so too.
290
+ */
291
+ serverWord(
292
+ element: Element,
293
+ name: string,
294
+ options?: { flag?: boolean; presence?: boolean },
295
+ ): string | null
296
+ /**
297
+ * Writes `value` for `name` on `element`, through `setAttr` or, for a
298
+ * `flag`, `setFlag`, and notes it against the server's word, compared by
299
+ * presence for a `flag` or with `presence`. With `mark`,
300
+ * the attribute is marked as the component's when the value differs from
301
+ * the server's, so a morph that removes the mark is seen as the server
302
+ * speaking.
303
+ */
304
+ write(
305
+ element: Element,
306
+ name: string,
307
+ value: string | null,
308
+ options?: {
309
+ /**
310
+ * The server's word the value was written against. Left out for an
311
+ * attribute the component never reads back as the server's, such as
312
+ * `for` written from the control's id: then nothing is remembered as
313
+ * the server's, and a later read sees what stands there.
314
+ */
315
+ server?: string | null
316
+ mark?: boolean
317
+ flag?: boolean
318
+ presence?: boolean
319
+ },
320
+ ): void
321
+ /** What the component last wrote for `name` on `element`, or `undefined`. */
322
+ written(element: Element, name: string): string | null | undefined
323
+ /**
324
+ * Forgets what the component wrote for `name` on `element`: the server has
325
+ * taken the attribute over, and the same value later is the server's. The
326
+ * mark goes too, so the markup does not say a value the server owns is
327
+ * the component's.
328
+ */
329
+ forget(element: Element, name: string): void
330
+ /**
331
+ * Removes `name` from `element` and forgets it in the same step, for an
332
+ * attribute the component wrote and no longer wants. Removing without
333
+ * forgetting left the component believing the attribute was its own, and
334
+ * it could later remove one the server had written.
335
+ */
336
+ release(element: Element, name: string, options?: { flag?: boolean }): void
337
+ /**
338
+ * Whether a morph from the server has been there since the component
339
+ * wrote `name` on `element`: it marked a value that differed from the
340
+ * server's word, and the mark is gone. A value that did not differ had no
341
+ * mark to lose, and its absence says nothing.
342
+ */
343
+ lost(element: Element, name: string): boolean
344
+ }
345
+
346
+ export function ownership(): Ownership {
347
+ const memory = new WeakMap<
348
+ Element,
349
+ Map<
350
+ string,
351
+ {
352
+ written: string | null
353
+ /** `undefined` when the write gave no server's word. */
354
+ server: string | null | undefined
355
+ marked: boolean
356
+ /** Compared by whether it is there, as the write said. */
357
+ presence: boolean
358
+ }
359
+ >
360
+ >()
361
+ // By presence, a value is "" when the attribute is there and null when not.
362
+ const byPresence = (value: string | null) => (value === null ? null : "")
363
+ const read = (element: Element, name: string, presence: boolean) =>
364
+ presence
365
+ ? byPresence(element.getAttribute(name))
366
+ : element.getAttribute(name)
367
+ return {
368
+ knows: (element, name) => Boolean(memory.get(element)?.has(name)),
369
+ serverWord(element, name, { flag, presence } = {}) {
370
+ const known = memory.get(element)?.get(name)
371
+ // The comparison the write used, so a read that forgets the option
372
+ // still knows the component's own echo.
373
+ const now = read(
374
+ element,
375
+ name,
376
+ known?.presence ?? Boolean(flag || presence),
377
+ )
378
+ if (!known || known.server === undefined) return now
379
+ // The server's when the value is another, or, for an attribute written
380
+ // with the mark, when a morph from the server has been there since
381
+ // (`WRITTEN`). Without the mark, only a value that differs from the
382
+ // component's own says the server spoke.
383
+ return now !== known.written ||
384
+ (known.marked && !stillMarked(element, name))
385
+ ? now
386
+ : known.server
387
+ },
388
+ write(element, name, value, { server, mark, flag, presence } = {}) {
389
+ if (flag) setFlag(element, name, value !== null)
390
+ else setAttr(element, name, value)
391
+ const compared = Boolean(flag || presence)
392
+ // Both sides in the same form, so `disabled="disabled"` from the
393
+ // server and the component's `disabled` are the same word.
394
+ const written = compared ? byPresence(value) : value
395
+ const said =
396
+ server === undefined
397
+ ? undefined
398
+ : compared
399
+ ? byPresence(server)
400
+ : server
401
+ // The mark says the value differs from the server's word, so without
402
+ // one there is nothing to mark against.
403
+ if (mark && said !== undefined)
404
+ markWritten(element, name, written !== said)
405
+ let attributes = memory.get(element)
406
+ if (!attributes) {
407
+ attributes = new Map()
408
+ memory.set(element, attributes)
409
+ }
410
+ attributes.set(name, {
411
+ written,
412
+ server: said,
413
+ marked: Boolean(mark),
414
+ presence: compared,
415
+ })
416
+ },
417
+ written: (element, name) => memory.get(element)?.get(name)?.written,
418
+ forget(element, name) {
419
+ if (memory.get(element)?.get(name)?.marked)
420
+ markWritten(element, name, false)
421
+ memory.get(element)?.delete(name)
422
+ },
423
+ release(element, name, { flag } = {}) {
424
+ if (flag) setFlag(element, name, false)
425
+ else setAttr(element, name, null)
426
+ if (memory.get(element)?.get(name)?.marked)
427
+ markWritten(element, name, false)
428
+ memory.get(element)?.delete(name)
429
+ },
430
+ lost(element, name) {
431
+ const known = memory.get(element)?.get(name)
432
+ return Boolean(
433
+ known?.marked &&
434
+ known.server !== undefined &&
435
+ known.written !== known.server &&
436
+ !stillMarked(element, name),
437
+ )
438
+ },
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Whether `element` has a name from `aria-labelledby` or `aria-label`. A
444
+ * blank one names nothing. The rule `nameFromHeading()` reads by, for the
445
+ * warnings about an element without a name.
446
+ */
447
+ export function hasName(element: Element): boolean {
448
+ return Boolean(
449
+ element.getAttribute("aria-labelledby")?.trim() ||
450
+ element.getAttribute("aria-label")?.trim(),
451
+ )
452
+ }
453
+
454
+ /**
455
+ * Names `element` from a heading with `aria-labelledby`, unless the server
456
+ * named it, and says how it is named.
457
+ *
458
+ * - `"server"`: an `aria-labelledby` the component did not write, or wrote
459
+ * and the server has sent since. It stays, and the component forgets its
460
+ * own.
461
+ * - `"label"`: the server gave an `aria-label`, which `aria-labelledby` would
462
+ * otherwise win over. The component lets go of its own.
463
+ * - `"heading"`: `heading()` gave an id, and `aria-labelledby` points at it,
464
+ * written with the mark, so a morph that sends the same id makes it the
465
+ * server's. It follows a heading a patch replaced.
466
+ * - `"none"`: no name and no heading. The component lets go of its own, and
467
+ * the caller says so with `warnAboutMarkup`.
468
+ *
469
+ * A blank `aria-labelledby` or `aria-label` names nothing, so it counts as
470
+ * missing. `heading()` is only called when the server has not named the
471
+ * element, so a caller that gives the heading an id does so only then.
472
+ * dialog, session-timeout and error-summary name themselves through it,
473
+ * and `host` is the web component that says so when the markup came with
474
+ * the mark copied in.
475
+ */
476
+ export function nameFromHeading(
477
+ own: Ownership,
478
+ host: Element,
479
+ element: Element,
480
+ heading: () => string | null,
481
+ ): "server" | "label" | "heading" | "none" {
482
+ const NAME = "aria-labelledby"
483
+ if (!own.knows(element, NAME)) warnAboutCopiedMark(host, element)
484
+ const labelledBy = element.getAttribute(NAME)?.trim() || null
485
+ const label = element.getAttribute("aria-label")?.trim() || null
486
+ const ours =
487
+ labelledBy !== null &&
488
+ own.knows(element, NAME) &&
489
+ own.serverWord(element, NAME) === null
490
+ if (labelledBy !== null && !ours) {
491
+ own.forget(element, NAME)
492
+ return "server"
493
+ }
494
+ if (label !== null) {
495
+ if (ours) own.release(element, NAME)
496
+ return "label"
497
+ }
498
+ const id = heading()
499
+ if (!id) {
500
+ if (ours) own.release(element, NAME)
501
+ return "none"
502
+ }
503
+ own.write(element, NAME, id, { server: null, mark: true })
504
+ return "heading"
505
+ }
506
+
507
+ /**
508
+ * A `MutationObserver` a web component starts on a node and stops again.
509
+ *
510
+ * Started again on another node, it stops watching the old one first, so a
511
+ * node the server has replaced does not go on calling the component. field
512
+ * watches through it.
513
+ */
514
+ export function watcher(
515
+ callback: (records: MutationRecord[]) => void,
516
+ init: MutationObserverInit,
517
+ ): {
518
+ start(target: Node): void
519
+ stop(): void
520
+ /** Whether it was started and not stopped since. */
521
+ readonly watching: boolean
522
+ } {
523
+ let observer: MutationObserver | undefined
524
+ return {
525
+ get watching() {
526
+ return observer !== undefined
527
+ },
528
+ start(target) {
529
+ observer?.disconnect()
530
+ observer = new MutationObserver(callback)
531
+ observer.observe(target, init)
532
+ },
533
+ stop() {
534
+ observer?.disconnect()
535
+ observer = undefined
536
+ },
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Puts `open` back on a `<dialog>` that is still in the top layer, when a
542
+ * morph took the attribute the server never sent, and says whether it did.
543
+ *
544
+ * The condition is `:modal` and not `open`: a dialog the user has closed
545
+ * leaves the top layer, and one a morph took `open` from stays there,
546
+ * invisible, with the rest of the page inert, and `close()` does nothing on
547
+ * it until the attribute is back.
548
+ */
549
+ export function restoreModalOpen(
550
+ dialog: HTMLDialogElement | null | undefined,
551
+ ): boolean {
552
+ if (!dialog?.matches(":modal") || dialog.open) return false
553
+ setAttr(dialog, "open", "")
554
+ return true
555
+ }
556
+
268
557
  /**
269
558
  * Turns a boolean attribute on or off, without writing when it is already so.
270
559
  *
@@ -382,14 +671,16 @@ export function derivedParts<T extends Element>(
382
671
  host: Element,
383
672
  selector: string,
384
673
  role: string,
385
- roledByHost: WeakSet<Element>,
674
+ /** The `ownership()` the component wrote the roles through. */
675
+ roledByHost: Pick<Ownership, "written">,
386
676
  ): T[] {
677
+ const mine = (part: Element) => roledByHost.written(part, "role") === role
387
678
  const own = [...host.querySelectorAll<T>(selector)].filter(
388
679
  (part) => part.closest(host.localName) === host,
389
680
  )
390
681
  const bare = own
391
682
  .filter((part) => part.getAttribute("role") === role)
392
- .every((part) => roledByHost.has(part))
683
+ .every(mine)
393
684
  return bare ? own : own.filter((part) => part.getAttribute("role") === role)
394
685
  }
395
686
 
@@ -456,6 +747,16 @@ export function warnAboutMarkup(
456
747
  element: Element,
457
748
  message: string,
458
749
  stillBroken: () => boolean,
750
+ {
751
+ silentWhenEmpty = false,
752
+ }: {
753
+ /**
754
+ * An element with no children is a region the server has not filled
755
+ * yet, which is not an error in the markup. field uses it, in place of
756
+ * `childElementCount > 0` in its `stillBroken`.
757
+ */
758
+ silentWhenEmpty?: boolean
759
+ } = {},
459
760
  ): void {
460
761
  if (typeof console === "undefined") return
461
762
  if (reported.get(element)?.has(message)) return
@@ -470,7 +771,9 @@ export function warnAboutMarkup(
470
771
 
471
772
  whenSettled(() => {
472
773
  pending.delete(message)
473
- if (!element.isConnected || !stillBroken()) return
774
+ if (!element.isConnected) return
775
+ if (silentWhenEmpty && element.childElementCount === 0) return
776
+ if (!stillBroken()) return
474
777
 
475
778
  let said = reported.get(element)
476
779
  if (!said) {