@lo-tek/interfaces 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +137 -2
  2. package/README.md +4 -3
  3. package/agent/astro.md +1 -1
  4. package/agent/bundles.md +1 -1
  5. package/agent/datastar.md +48 -48
  6. package/agent/html.md +51 -51
  7. package/agent/react.md +1 -1
  8. package/agent/templates.md +49 -49
  9. package/core/lotek-core.wasm +0 -0
  10. package/core/lotek.wasm +0 -0
  11. package/dist/components/default-texts.d.ts +6 -0
  12. package/dist/components/default-texts.d.ts.map +1 -1
  13. package/dist/components/default-texts.js +6 -0
  14. package/dist/components/default-texts.js.map +1 -1
  15. package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -1
  16. package/dist/components/frame/dialog/lt-dialog.js +5 -2
  17. package/dist/components/frame/dialog/lt-dialog.js.map +1 -1
  18. package/dist/components/frame/error-summary/error-summary.d.ts +15 -2
  19. package/dist/components/frame/error-summary/error-summary.d.ts.map +1 -1
  20. package/dist/components/frame/error-summary/error-summary.js +33 -11
  21. package/dist/components/frame/error-summary/error-summary.js.map +1 -1
  22. package/dist/components/frame/error-summary/lt-error-summary.d.ts +11 -0
  23. package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -1
  24. package/dist/components/frame/error-summary/lt-error-summary.js +114 -8
  25. package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -1
  26. package/dist/components/frame/field/lt-field.d.ts.map +1 -1
  27. package/dist/components/frame/field/lt-field.js +34 -14
  28. package/dist/components/frame/field/lt-field.js.map +1 -1
  29. package/dist/components/frame/popover/lt-popover.d.ts +6 -2
  30. package/dist/components/frame/popover/lt-popover.d.ts.map +1 -1
  31. package/dist/components/frame/popover/lt-popover.js +44 -14
  32. package/dist/components/frame/popover/lt-popover.js.map +1 -1
  33. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -1
  34. package/dist/components/frame/session-timeout/lt-session-timeout.js +43 -6
  35. package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -1
  36. package/dist/components/frame/suggestion/lt-suggestion.d.ts +8 -2
  37. package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -1
  38. package/dist/components/frame/suggestion/lt-suggestion.js +56 -37
  39. package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -1
  40. package/dist/components/frame/tabs/lt-tabs.d.ts +32 -10
  41. package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -1
  42. package/dist/components/frame/tabs/lt-tabs.js +85 -21
  43. package/dist/components/frame/tabs/lt-tabs.js.map +1 -1
  44. package/dist/components/host-element.d.ts +49 -0
  45. package/dist/components/host-element.d.ts.map +1 -1
  46. package/dist/components/host-element.js +74 -0
  47. package/dist/components/host-element.js.map +1 -1
  48. package/dist/components/standalone/connection-status/connection-status.d.ts +5 -1
  49. package/dist/components/standalone/connection-status/connection-status.d.ts.map +1 -1
  50. package/dist/components/standalone/connection-status/connection-status.js +5 -1
  51. package/dist/components/standalone/connection-status/connection-status.js.map +1 -1
  52. package/dist/components/standalone/connection-status/lt-connection-status.d.ts +11 -6
  53. package/dist/components/standalone/connection-status/lt-connection-status.d.ts.map +1 -1
  54. package/dist/components/standalone/connection-status/lt-connection-status.js +34 -10
  55. package/dist/components/standalone/connection-status/lt-connection-status.js.map +1 -1
  56. package/dist/diagnostics/core.d.ts +34 -0
  57. package/dist/diagnostics/core.d.ts.map +1 -1
  58. package/dist/diagnostics/core.js +14 -2
  59. package/dist/diagnostics/core.js.map +1 -1
  60. package/dist/lotek.css +1 -1
  61. package/dist/lt.d.ts +1 -1
  62. package/dist/react.d.ts +2 -0
  63. package/dist/react.d.ts.map +1 -1
  64. package/dist/vocabulary/classes.d.ts.map +1 -1
  65. package/dist/vocabulary/classes.js +34 -0
  66. package/dist/vocabulary/classes.js.map +1 -1
  67. package/dist/vocabulary/elements.d.ts.map +1 -1
  68. package/dist/vocabulary/elements.js +87 -7
  69. package/dist/vocabulary/elements.js.map +1 -1
  70. package/dist/vocabulary/types.d.ts +54 -0
  71. package/dist/vocabulary/types.d.ts.map +1 -1
  72. package/live-templates.xml +3 -2
  73. package/manifest/manifest.json +133 -8
  74. package/manifest/manifest.schema.json +73 -0
  75. package/package.json +2 -1
  76. package/src/components/default-texts.ts +7 -0
  77. package/src/components/frame/dialog/lt-dialog.ts +5 -2
  78. package/src/components/frame/error-summary/error-summary.ts +45 -11
  79. package/src/components/frame/error-summary/lt-error-summary.ts +121 -7
  80. package/src/components/frame/field/lt-field.ts +65 -28
  81. package/src/components/frame/popover/lt-popover.ts +43 -12
  82. package/src/components/frame/session-timeout/lt-session-timeout.ts +40 -6
  83. package/src/components/frame/suggestion/lt-suggestion.ts +57 -36
  84. package/src/components/frame/tabs/lt-tabs.ts +114 -38
  85. package/src/components/host-element.ts +93 -0
  86. package/src/components/standalone/connection-status/connection-status.ts +7 -1
  87. package/src/components/standalone/connection-status/lt-connection-status.ts +36 -10
  88. package/src/diagnostics/core.ts +19 -2
  89. package/src/vocabulary/classes.ts +34 -0
  90. package/src/vocabulary/elements.ts +87 -7
  91. package/src/vocabulary/types.ts +50 -0
  92. package/web-types.json +1 -1
@@ -24,6 +24,10 @@
24
24
  "type": "string",
25
25
  "description": "The version of @lo-tek/interfaces the manifest was written from."
26
26
  },
27
+ "textLanguage": {
28
+ "type": "string",
29
+ "description": "The language of the texts the components and stylesheets write themselves. lotek check warns about one of them on a page in another language."
30
+ },
27
31
  "elements": {
28
32
  "type": "object",
29
33
  "description": "Every <lt-…> element, with the attributes the component reads.",
@@ -51,6 +55,26 @@
51
55
  "$ref": "#/$defs/part"
52
56
  }
53
57
  },
58
+ "name": {
59
+ "$ref": "#/$defs/name"
60
+ },
61
+ "texts": {
62
+ "type": "object",
63
+ "description": "The texts the element writes itself, by their name in DEFAULT_TEXTS.",
64
+ "additionalProperties": {
65
+ "type": "object",
66
+ "required": ["text", "replacedBy"],
67
+ "properties": {
68
+ "text": { "type": "string" },
69
+ "replacedBy": {
70
+ "type": "array",
71
+ "description": "The attributes that replace the text. Any one of them is enough.",
72
+ "items": { "type": "string" }
73
+ }
74
+ },
75
+ "additionalProperties": false
76
+ }
77
+ },
54
78
  "events": {
55
79
  "type": "object",
56
80
  "description": "The events the element dispatches. All of them bubble and cross shadow boundaries. Absent when the element dispatches nothing.",
@@ -104,6 +128,29 @@
104
128
  "type": "string",
105
129
  "description": "The class this one is part of, when it is only a part of another component's markup, like lt-card__title in lt-card. The parent has a part that matches it."
106
130
  },
131
+ "name": {
132
+ "$ref": "#/$defs/name"
133
+ },
134
+ "texts": {
135
+ "type": "array",
136
+ "description": "The texts the stylesheet shows on the element with the class and the attribute, and the CSS variable that replaces each.",
137
+ "items": {
138
+ "type": "object",
139
+ "required": ["variable", "text", "attribute"],
140
+ "properties": {
141
+ "variable": { "type": "string" },
142
+ "text": { "type": "string" },
143
+ "attribute": { "type": "string" },
144
+ "value": { "type": "string" },
145
+ "unless": {
146
+ "type": "array",
147
+ "description": "Attributes that keep the text from showing.",
148
+ "items": { "type": "string" }
149
+ }
150
+ },
151
+ "additionalProperties": false
152
+ }
153
+ },
107
154
  "parts": {
108
155
  "type": "object",
109
156
  "description": "The parts the markup consists of, for the classes with a fixed structure, with the selector relative to the element with the class. Absent for the classes without one.",
@@ -505,6 +552,32 @@
505
552
  "optional": {
506
553
  "const": true,
507
554
  "description": "The element works without the part."
555
+ },
556
+ "stableId": {
557
+ "const": true,
558
+ "description": "The part needs an id from the server: the component never writes one."
559
+ },
560
+ "name": {
561
+ "$ref": "#/$defs/name"
562
+ }
563
+ },
564
+ "additionalProperties": false
565
+ },
566
+ "name": {
567
+ "type": "object",
568
+ "description": "A name LO-TEK demands where WAI-ARIA does not, or one the component gives itself. lotek check reports an element the markup gives no name.",
569
+ "properties": {
570
+ "after": {
571
+ "type": "string",
572
+ "description": "The part the component points aria-labelledby at when the markup has neither aria-label nor aria-labelledby."
573
+ },
574
+ "id": {
575
+ "const": true,
576
+ "description": "That part must have an id from the template: the component does not write one."
577
+ },
578
+ "component": {
579
+ "const": true,
580
+ "description": "The component names it itself, and no rule judges its name in the markup."
508
581
  }
509
582
  },
510
583
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lo-tek/interfaces",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Framework-agnostic design system: tokens, CSS components and web components.",
5
5
  "license": "MIT",
6
6
  "author": "August Søberg-Klyver",
@@ -367,6 +367,7 @@
367
367
  "typecheck": "tsc --noEmit",
368
368
  "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
369
369
  "test:browser": "vitest --config vitest.config.ts --run",
370
+ "test:model": "vitest --config vitest.model.config.ts --run",
370
371
  "check:exports": "bun scripts/check-exports.ts",
371
372
  "check:server": "bun scripts/check-server-import.ts",
372
373
  "check:types": "bun scripts/check-types.ts",
@@ -16,6 +16,13 @@
16
16
  * read from an `--lt-` variable with the text as the fallback, and
17
17
  * `check-texts.ts` requires that.
18
18
  */
19
+ /**
20
+ * The language the texts are in. `lotek check` warns about a component that
21
+ * shows one of them on a page in another language, without the attribute or
22
+ * CSS variable that replaces it.
23
+ */
24
+ export const DEFAULT_LANGUAGE = "en"
25
+
19
26
  export const DEFAULT_TEXTS = {
20
27
  /** `aria-label` on the `<lt-toast>` region. Replaced with `label`. */
21
28
  toastRegion: "Notifications",
@@ -159,7 +159,9 @@ export class LtDialog extends HostElement {
159
159
  childList: true,
160
160
  subtree: true,
161
161
  attributes: true,
162
- attributeFilter: ["class", "id", "aria-labelledby"],
162
+ // `aria-label` too: removed, the dialog is named after its title
163
+ // again, and without it here the dialog was left without a name.
164
+ attributeFilter: ["class", "id", "aria-labelledby", "aria-label"],
163
165
  })
164
166
  this.sync()
165
167
  }
@@ -284,7 +286,8 @@ export class LtDialog extends HostElement {
284
286
  }
285
287
 
286
288
  private notify(open: boolean, returnValue = ""): void {
287
- if (open === this.lastNotified) return
289
+ // A dialog starts closed: one that never opened reported a close.
290
+ if (open === (this.lastNotified ?? false)) return
288
291
  this.lastNotified = open
289
292
  this.dispatchEvent(
290
293
  new CustomEvent("dialog-toggle", {
@@ -8,6 +8,13 @@ export type ErrorSummaryOptions = {
8
8
  count?: number
9
9
  /** The id of the box, so the form can move focus here after submission. */
10
10
  id?: string
11
+ /**
12
+ * The id of the heading. The box takes its name from it with
13
+ * `aria-labelledby`. Default: the box's `id` with `-title`, so the markup
14
+ * carries the wiring whenever the box has an id. An empty one counts as
15
+ * none; one with whitespace throws.
16
+ */
17
+ titleId?: string
11
18
  }
12
19
 
13
20
  export type ErrorSummaryAttributes = {
@@ -17,9 +24,11 @@ export type ErrorSummaryAttributes = {
17
24
  tabindex: "-1"
18
25
  id?: string
19
26
  hidden?: true
27
+ "aria-labelledby"?: string
20
28
  }
21
29
  title: {
22
30
  class: typeof ERROR_SUMMARY_TITLE_CLASS
31
+ id?: string
23
32
  }
24
33
  }
25
34
 
@@ -32,7 +41,11 @@ export type ErrorSummaryAttributes = {
32
41
  * every morph in Datastar.
33
42
  *
34
43
  * ```ts
35
- * const summary = lt.errorSummary({ count: errors.length, id: "errors" })
44
+ * const summary = lt.errorSummary({
45
+ * count: errors.length,
46
+ * id: "errors",
47
+ * titleId: "errors-title",
48
+ * })
36
49
  * ```
37
50
  * ```html
38
51
  * <lt-error-summary {...summary.host}>
@@ -44,13 +57,34 @@ export type ErrorSummaryAttributes = {
44
57
  export const errorSummary = ({
45
58
  count = 0,
46
59
  id,
47
- }: ErrorSummaryOptions = {}): ErrorSummaryAttributes => ({
48
- host: attributes({
49
- class: ERROR_SUMMARY_CLASS,
50
- role: "alert" as const,
51
- tabindex: "-1" as const,
52
- id,
53
- hidden: count === 0 ? (true as const) : undefined,
54
- }),
55
- title: attributes({ class: ERROR_SUMMARY_TITLE_CLASS }),
56
- })
60
+ titleId: givenTitleId,
61
+ }: ErrorSummaryOptions = {}): ErrorSummaryAttributes => {
62
+ /*
63
+ * The heading's id is written here, by whoever renders, never by the web
64
+ * component: Datastar and idiomorph replace an element whose id the server
65
+ * did not send, so an id from the component gave the heading a new one on
66
+ * every patch. An empty one names nothing.
67
+ */
68
+ const titleId = givenTitleId?.trim()
69
+ ? givenTitleId
70
+ : id?.trim()
71
+ ? `${id}-title`
72
+ : undefined
73
+ // `aria-labelledby` splits on whitespace, so the box would point at two
74
+ // ids, neither of them the heading's.
75
+ if (titleId !== undefined && /\s/.test(titleId))
76
+ throw new Error(
77
+ `lt.errorSummary(): the titleId "${titleId}" contains whitespace. aria-labelledby splits on whitespace, so the box would point at ids that are not the heading's.`,
78
+ )
79
+ return {
80
+ host: attributes({
81
+ class: ERROR_SUMMARY_CLASS,
82
+ role: "alert" as const,
83
+ tabindex: "-1" as const,
84
+ id,
85
+ hidden: count === 0 ? (true as const) : undefined,
86
+ "aria-labelledby": titleId,
87
+ }),
88
+ title: attributes({ class: ERROR_SUMMARY_TITLE_CLASS, id: titleId }),
89
+ }
90
+ }
@@ -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. */