@lo-tek/interfaces 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +160 -2
  2. package/README.md +4 -3
  3. package/agent/astro.md +1 -1
  4. package/agent/bundles.md +4 -2
  5. package/agent/datastar.md +48 -48
  6. package/agent/html.md +51 -51
  7. package/agent/react.md +6 -5
  8. package/agent/templates.md +49 -49
  9. package/core/lotek-core.wasm +0 -0
  10. package/core/lotek.wasm +0 -0
  11. package/dist/components/default-texts.d.ts +6 -0
  12. package/dist/components/default-texts.d.ts.map +1 -1
  13. package/dist/components/default-texts.js +6 -0
  14. package/dist/components/default-texts.js.map +1 -1
  15. package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -1
  16. package/dist/components/frame/dialog/lt-dialog.js +5 -2
  17. package/dist/components/frame/dialog/lt-dialog.js.map +1 -1
  18. package/dist/components/frame/error-summary/error-summary.d.ts +15 -2
  19. package/dist/components/frame/error-summary/error-summary.d.ts.map +1 -1
  20. package/dist/components/frame/error-summary/error-summary.js +33 -11
  21. package/dist/components/frame/error-summary/error-summary.js.map +1 -1
  22. package/dist/components/frame/error-summary/lt-error-summary.d.ts +11 -0
  23. package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -1
  24. package/dist/components/frame/error-summary/lt-error-summary.js +114 -8
  25. package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -1
  26. package/dist/components/frame/field/lt-field.d.ts.map +1 -1
  27. package/dist/components/frame/field/lt-field.js +34 -14
  28. package/dist/components/frame/field/lt-field.js.map +1 -1
  29. package/dist/components/frame/popover/lt-popover.d.ts +6 -2
  30. package/dist/components/frame/popover/lt-popover.d.ts.map +1 -1
  31. package/dist/components/frame/popover/lt-popover.js +44 -14
  32. package/dist/components/frame/popover/lt-popover.js.map +1 -1
  33. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -1
  34. package/dist/components/frame/session-timeout/lt-session-timeout.js +43 -6
  35. package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -1
  36. package/dist/components/frame/suggestion/lt-suggestion.d.ts +8 -2
  37. package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -1
  38. package/dist/components/frame/suggestion/lt-suggestion.js +56 -37
  39. package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -1
  40. package/dist/components/frame/tabs/lt-tabs.d.ts +32 -10
  41. package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -1
  42. package/dist/components/frame/tabs/lt-tabs.js +85 -21
  43. package/dist/components/frame/tabs/lt-tabs.js.map +1 -1
  44. package/dist/components/host-element.d.ts +52 -0
  45. package/dist/components/host-element.d.ts.map +1 -1
  46. package/dist/components/host-element.js +164 -1
  47. package/dist/components/host-element.js.map +1 -1
  48. package/dist/components/standalone/connection-status/connection-status.d.ts +5 -1
  49. package/dist/components/standalone/connection-status/connection-status.d.ts.map +1 -1
  50. package/dist/components/standalone/connection-status/connection-status.js +5 -1
  51. package/dist/components/standalone/connection-status/connection-status.js.map +1 -1
  52. package/dist/components/standalone/connection-status/lt-connection-status.d.ts +11 -6
  53. package/dist/components/standalone/connection-status/lt-connection-status.d.ts.map +1 -1
  54. package/dist/components/standalone/connection-status/lt-connection-status.js +34 -10
  55. package/dist/components/standalone/connection-status/lt-connection-status.js.map +1 -1
  56. package/dist/diagnostics/core.d.ts +34 -0
  57. package/dist/diagnostics/core.d.ts.map +1 -1
  58. package/dist/diagnostics/core.js +14 -2
  59. package/dist/diagnostics/core.js.map +1 -1
  60. package/dist/lotek.css +1 -1
  61. package/dist/lt.d.ts +1 -1
  62. package/dist/react.d.ts +2 -0
  63. package/dist/react.d.ts.map +1 -1
  64. package/dist/vocabulary/classes.d.ts.map +1 -1
  65. package/dist/vocabulary/classes.js +34 -0
  66. package/dist/vocabulary/classes.js.map +1 -1
  67. package/dist/vocabulary/elements.d.ts.map +1 -1
  68. package/dist/vocabulary/elements.js +87 -7
  69. package/dist/vocabulary/elements.js.map +1 -1
  70. package/dist/vocabulary/types.d.ts +60 -0
  71. package/dist/vocabulary/types.d.ts.map +1 -1
  72. package/live-templates.xml +3 -2
  73. package/manifest/manifest.json +172 -8
  74. package/manifest/manifest.schema.json +80 -0
  75. package/package.json +2 -1
  76. package/src/components/default-texts.ts +7 -0
  77. package/src/components/frame/dialog/lt-dialog.ts +5 -2
  78. package/src/components/frame/error-summary/error-summary.ts +45 -11
  79. package/src/components/frame/error-summary/lt-error-summary.ts +121 -7
  80. package/src/components/frame/field/lt-field.ts +65 -28
  81. package/src/components/frame/popover/lt-popover.ts +43 -12
  82. package/src/components/frame/session-timeout/lt-session-timeout.ts +40 -6
  83. package/src/components/frame/suggestion/lt-suggestion.ts +57 -36
  84. package/src/components/frame/tabs/lt-tabs.ts +114 -38
  85. package/src/components/host-element.ts +190 -1
  86. package/src/components/standalone/connection-status/connection-status.ts +7 -1
  87. package/src/components/standalone/connection-status/lt-connection-status.ts +36 -10
  88. package/src/diagnostics/core.ts +19 -2
  89. package/src/vocabulary/classes.ts +34 -0
  90. package/src/vocabulary/elements.ts +87 -7
  91. package/src/vocabulary/types.ts +56 -0
  92. package/web-types.json +1 -1
@@ -12,12 +12,108 @@
12
12
  * without `customElements` nothing is registered, and without registration
13
13
  * the browser never creates an instance. It only exists so the module can
14
14
  * load.
15
+ *
16
+ * In the browser the base does one thing: it adopts the properties a script
17
+ * set before the registration, see `adoptPropertiesSetBeforeUpgrade()`.
15
18
  */
16
19
 
17
20
  export const HostElement =
18
21
  typeof HTMLElement === "undefined"
19
22
  ? (class {} as unknown as typeof HTMLElement)
20
- : HTMLElement
23
+ : (class extends HTMLElement {
24
+ constructor() {
25
+ super()
26
+ // Here the own properties are exactly what a script set before the
27
+ // upgrade: the component's own fields are assigned after `super()`
28
+ // returns. An element created after the registration has none, and
29
+ // costs nothing.
30
+ const names = Object.getOwnPropertyNames(this)
31
+ if (names.length === 0) return
32
+ const pending = holdPropertiesSetBeforeUpgrade(this, names)
33
+ if (pending.size === 0) return
34
+ // Written in a microtask, not here: the field initialisers run
35
+ // after `super()`, and a setter that notes something on the
36
+ // instance, as `<lt-popover>` does with `open`, would have been
37
+ // overwritten by them. By the microtask the whole constructor and
38
+ // the upgrade's `connectedCallback` are done.
39
+ queueMicrotask(() => {
40
+ for (const [name, value] of pending) {
41
+ Reflect.deleteProperty(this, name)
42
+ ;(this as unknown as Record<string, unknown>)[name] = value
43
+ }
44
+ })
45
+ }
46
+ } as unknown as typeof HTMLElement)
47
+
48
+ /**
49
+ * Takes the properties a script set on the element before it was upgraded out
50
+ * of the way, so they can be written through the component's setters.
51
+ *
52
+ * `dialog.open = true` from a script that runs before `defineLt()` lands on a
53
+ * plain `HTMLElement`, and becomes an own data property. When the element is
54
+ * upgraded, that property shadows the accessor on the prototype: the
55
+ * attribute is never set, and every later write, `dialog.open = false`
56
+ * included, goes to the shadow and never reaches the setter. Tested against
57
+ * `dist` on 2026-11-01: the setters were dead for the rest of the element's
58
+ * life.
59
+ *
60
+ * Each such property is replaced by a stand-in for the time until the
61
+ * microtask that writes the value. A read goes to the getter, so the
62
+ * component's first `sync()` sees the attribute and not the script's value:
63
+ * when it saw the value, it opened a dialog the script closed again right
64
+ * after the registration, and the write of `false` then removed an attribute
65
+ * that was never there, so nothing closed the dialog (found by GitHub's
66
+ * review). A write goes to the setter and cancels the adoption, so the last
67
+ * write wins. The values returned are what the microtask writes.
68
+ *
69
+ * Only a property with a setter somewhere on the prototype chain is taken;
70
+ * anything else a script put on the element stays as it is. A property set
71
+ * after the upgrade is never an own property, so there is nothing to do for
72
+ * an element created once the registration is there. A property someone made
73
+ * non-configurable cannot be moved out of the way, and is left shadowing the
74
+ * setter, as before. (Documented: the "lazy properties" pattern in Google's
75
+ * custom elements best practices.)
76
+ */
77
+ function holdPropertiesSetBeforeUpgrade(
78
+ element: HTMLElement,
79
+ names: readonly string[],
80
+ ): Map<string, unknown> {
81
+ const pending = new Map<string, unknown>()
82
+ for (const name of names) {
83
+ const own = Object.getOwnPropertyDescriptor(element, name)
84
+ if (!own || !("value" in own) || !own.configurable) continue
85
+ const accessor = accessorOnPrototype(element, name)
86
+ if (!accessor?.set) continue
87
+ const { get, set } = accessor
88
+ pending.set(name, own.value)
89
+ Object.defineProperty(element, name, {
90
+ configurable: true,
91
+ enumerable: true,
92
+ get: () => get?.call(element),
93
+ set: (value: unknown) => {
94
+ pending.delete(name)
95
+ Reflect.deleteProperty(element, name)
96
+ set.call(element, value)
97
+ },
98
+ })
99
+ }
100
+ return pending
101
+ }
102
+
103
+ function accessorOnPrototype(
104
+ element: object,
105
+ name: string,
106
+ ): PropertyDescriptor | undefined {
107
+ for (
108
+ let proto = Object.getPrototypeOf(element);
109
+ proto !== null;
110
+ proto = Object.getPrototypeOf(proto)
111
+ ) {
112
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name)
113
+ if (descriptor) return descriptor
114
+ }
115
+ return undefined
116
+ }
21
117
 
22
118
  /*
23
119
  * The types of the events, such as `dialog-toggle` with `detail.open`. Every
@@ -96,6 +192,79 @@ export function setAttr(
96
192
  }
97
193
  }
98
194
 
195
+ /**
196
+ * The attribute a component marks what it wrote with: the names of the
197
+ * attributes on the element whose value is the component's, separated by
198
+ * spaces.
199
+ *
200
+ * Some attributes are both the server's and the component's: `aria-invalid`
201
+ * on a field, `aria-selected` on a tab. To tell the server's word from its
202
+ * own echo, a component remembered the value it wrote, and read anything
203
+ * else as the server's. A server that sent the same value was never seen: a
204
+ * morph does not touch an attribute whose value is already right, and the
205
+ * component took the server's `aria-invalid="true"` for its own and removed
206
+ * it. The mark is what a morph does touch. The server never sends it, so a
207
+ * morph from the server removes it, and an attribute without the mark is the
208
+ * server's, whatever its value. A script that changes the element without a
209
+ * morph leaves the mark, and is seen as before, by the value.
210
+ *
211
+ * Only what differs from the server's word is marked. A value that is the
212
+ * server's has nothing to tell apart, and markup that is already right, as
213
+ * React renders `lt.tabs()` with the state, must not be touched: hydration
214
+ * reports every attribute it did not render.
215
+ */
216
+ export const WRITTEN = "data-lt-written"
217
+
218
+ /**
219
+ * Marks `name` on `element` as the component's when `mine`, that is, when
220
+ * what the component wrote differs from the server's word, and removes the
221
+ * mark otherwise.
222
+ */
223
+ export function markWritten(
224
+ element: Element,
225
+ name: string,
226
+ mine: boolean,
227
+ ): void {
228
+ const marked = new Set(
229
+ (element.getAttribute(WRITTEN) ?? "").split(/\s+/).filter(Boolean),
230
+ )
231
+ if (mine) marked.add(name)
232
+ else marked.delete(name)
233
+ setAttr(
234
+ element,
235
+ WRITTEN,
236
+ marked.size > 0 ? [...marked].sort().join(" ") : null,
237
+ )
238
+ }
239
+
240
+ /**
241
+ * Warns when `element` carries the mark before the component has written on
242
+ * it: the mark is in the markup it was given, copied into a template from
243
+ * the browser's developer tools. A morph then never removes it, and the
244
+ * component takes the server's word for its own echo again. Called where the
245
+ * component meets the element for the first time; the check cannot wait, so
246
+ * the warning is not checked again when the page has settled.
247
+ */
248
+ export function warnAboutCopiedMark(host: Element, element: Element): void {
249
+ if (!element.hasAttribute(WRITTEN)) return
250
+ warnAboutMarkup(
251
+ host,
252
+ `found ${WRITTEN} in the markup it was given. The browser writes the ` +
253
+ "attribute to tell the component's own values from the server's; " +
254
+ "copied into a template, it makes the component take what the " +
255
+ "server sends for its own. Remove it from the template.",
256
+ () => true,
257
+ )
258
+ }
259
+
260
+ /**
261
+ * Whether the component's mark for `name` is still on `element`: no morph
262
+ * from the server has been there since the component wrote it.
263
+ */
264
+ export function stillMarked(element: Element, name: string): boolean {
265
+ return (element.getAttribute(WRITTEN) ?? "").split(/\s+/).includes(name)
266
+ }
267
+
99
268
  /**
100
269
  * Turns a boolean attribute on or off, without writing when it is already so.
101
270
  *
@@ -175,6 +344,26 @@ export function uniqueId(prefix: string): string {
175
344
  return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
176
345
  }
177
346
 
347
+ /**
348
+ * The id an element should have: the one it has, otherwise the one the
349
+ * component created for that node before. A morph keeps the node and tears
350
+ * the id off, and it gets the same one back, so a screen reader does not
351
+ * follow a reference that changes under it.
352
+ */
353
+ export function rememberedId(
354
+ memory: WeakMap<Element, string>,
355
+ element: Element,
356
+ prefix: string,
357
+ ): string {
358
+ if (element.id) return element.id
359
+ let id = memory.get(element)
360
+ if (!id) {
361
+ id = uniqueId(prefix)
362
+ memory.set(element, id)
363
+ }
364
+ return id
365
+ }
366
+
178
367
  /**
179
368
  * The parts with a role, and those without only when the component set the
180
369
  * roles itself.
@@ -12,6 +12,8 @@ export type ConnectionStatusOptions = {
12
12
 
13
13
  export type ConnectionStatusAttributes = {
14
14
  class: typeof CONNECTION_STATUS_CLASS
15
+ role: "status"
16
+ "aria-live": "polite"
15
17
  "offline-text"?: string
16
18
  "online-text"?: string
17
19
  "data-ignore-morph": ""
@@ -23,7 +25,9 @@ export type ConnectionStatusAttributes = {
23
25
  * By definition the server cannot tell you it is unreachable. When it is
24
26
  * down, nothing comes from it to render. So the component creates all the
25
27
  * content itself, and `data-ignore-morph` stops a later patch from tearing
26
- * it away the moment the connection is back.
28
+ * it away the moment the connection is back. The host is the live region,
29
+ * `role="status"` with `aria-live="polite"`, so the region exists before the
30
+ * bar and its text arrive.
27
31
  *
28
32
  * ```ts
29
33
  * <lt-connection-status {...lt.connectionStatus()} />
@@ -35,6 +39,8 @@ export const connectionStatus = ({
35
39
  }: ConnectionStatusOptions = {}): ConnectionStatusAttributes =>
36
40
  attributes({
37
41
  class: CONNECTION_STATUS_CLASS,
42
+ role: "status" as const,
43
+ "aria-live": "polite" as const,
38
44
  "offline-text": offlineText,
39
45
  "online-text": onlineText,
40
46
  "data-ignore-morph": "" as const,
@@ -77,6 +77,23 @@ export class LtConnectionStatus extends HostElement {
77
77
 
78
78
  connectedCallback(): void {
79
79
  this.classList.add(CONNECTION_STATUS_CLASS)
80
+ /*
81
+ * The host is the live region, from the moment it is connected, so the
82
+ * region exists long before the bar and its text arrive. Only what is
83
+ * missing: a role or politeness the server wrote stays. When the bar was
84
+ * the region, it was inserted together with its text, and Firefox sent
85
+ * no live-region text through AT-SPI at all, so "No connection" was
86
+ * never announced. `check-accessibility-tree.ts` checks it.
87
+ */
88
+ this.markRegion()
89
+ // A morph without `data-ignore-morph` takes both off, and then the next
90
+ // outage is never announced. Writing only what is missing keeps the
91
+ // observer from triggering itself.
92
+ this.regionObserver = new MutationObserver(() => this.markRegion())
93
+ this.regionObserver.observe(this, {
94
+ attributes: true,
95
+ attributeFilter: ["role", "aria-live"],
96
+ })
80
97
  window.addEventListener("offline", this.handleOffline)
81
98
  window.addEventListener("online", this.handleOnline)
82
99
  if (!navigator.onLine) {
@@ -96,7 +113,22 @@ export class LtConnectionStatus extends HostElement {
96
113
  }
97
114
  }
98
115
 
116
+ private regionObserver?: MutationObserver
117
+
118
+ /**
119
+ * The region, as a pair and only when both are missing. A role the server
120
+ * wrote carries its own politeness (`alert` is assertive), and an
121
+ * `aria-live` added next to it would override that.
122
+ */
123
+ private markRegion(): void {
124
+ if (this.hasAttribute("role") || this.hasAttribute("aria-live")) return
125
+ this.setAttribute("role", "status")
126
+ this.setAttribute("aria-live", "polite")
127
+ }
128
+
99
129
  disconnectedCallback(): void {
130
+ this.regionObserver?.disconnect()
131
+ this.regionObserver = undefined
100
132
  window.removeEventListener("offline", this.handleOffline)
101
133
  window.removeEventListener("online", this.handleOnline)
102
134
  if (this.timer) window.clearTimeout(this.timer)
@@ -107,12 +139,10 @@ export class LtConnectionStatus extends HostElement {
107
139
  * The bar, created empty first and filled afterwards.
108
140
  *
109
141
  * A live region must exist before its content arrives, or assistive
110
- * technology does not announce the first change. When the bar was already
111
- * filled as it was inserted, the region appeared complete to the screen
112
- * reader, and "No connection", the most important message, could be lost.
113
- * So the text is set in the next frame when the bar is new. This cannot be
114
- * checked without a screen reader; the rule is known from NVDA, JAWS and
115
- * VoiceOver.
142
+ * technology does not announce the first change. The region is the host
143
+ * (see `connectedCallback`), so the bar is content inside a region that is
144
+ * already there; the text is still set in the next frame when the bar is
145
+ * new, so the bar and its text arrive as two changes.
116
146
  *
117
147
  * The bar is created only when it is needed, not on connection: when it
118
148
  * was in the DOM before React hydrated, React found an element it had not
@@ -123,10 +153,6 @@ export class LtConnectionStatus extends HostElement {
123
153
 
124
154
  const bar = document.createElement("div")
125
155
  bar.className = CONNECTION_STATUS_BAR_CLASS
126
- // polite and not assertive: the message matters, but should not
127
- // interrupt mid-word. It stays until the connection is back.
128
- bar.setAttribute("role", "status")
129
- bar.setAttribute("aria-live", "polite")
130
156
  this.append(bar)
131
157
  this.bar = bar
132
158
  return { bar, fresh: true }
@@ -148,12 +148,23 @@ type Exports = {
148
148
  inspect_theme_raw(pointer: number, length: number): number
149
149
  inspect_styles_raw(pointer: number, length: number): void
150
150
  diagnose_styled_raw(pointer: number, length: number): number
151
+ accessibility_tree_raw(pointer: number, length: number): number
152
+ accessibility_facts_raw(): void
153
+ accessibility_roles_raw(): void
154
+ accessibility_states_raw(): void
151
155
  result_ptr(): number
152
156
  result_len(): number
153
157
  }
154
158
 
155
- /** Loads the core from the module's bytes or an already compiled module. */
156
- export function loadCore(source: BufferSource | WebAssembly.Module): Core {
159
+ /**
160
+ * @internal Not part of the package's API.
161
+ *
162
+ * An instance of the core and the helpers every call goes through: the text
163
+ * written into the module's memory, and the answer read back. Not part of
164
+ * the package: `index.ts` exports `loadCore`. The tests reach the core's
165
+ * other functions, such as the accessibility tree, through this.
166
+ */
167
+ export function instantiate(source: BufferSource | WebAssembly.Module) {
157
168
  const module =
158
169
  source instanceof WebAssembly.Module
159
170
  ? source
@@ -181,6 +192,12 @@ export function loadCore(source: BufferSource | WebAssembly.Module): Core {
181
192
  if (status !== 0) throw new Error(read<{ error: string }>().error)
182
193
  return read<T>()
183
194
  }
195
+ return { e, write, read, answer }
196
+ }
197
+
198
+ /** Loads the core from the module's bytes or an already compiled module. */
199
+ export function loadCore(source: BufferSource | WebAssembly.Module): Core {
200
+ const { e, write, read, answer } = instantiate(source)
184
201
 
185
202
  return {
186
203
  diagnoseMarkup: (html) => answer(e.diagnose_markup_raw(...write(html))),
@@ -311,6 +311,7 @@ export const classes: Classes = {
311
311
  "optional": true
312
312
  }
313
313
  },
314
+ "name": {},
314
315
  "attributes": {
315
316
  "data-state": {
316
317
  "values": [
@@ -328,6 +329,22 @@ export const classes: Classes = {
328
329
  "link": "https://lo-tek.sobernetics.no/components/fieldset/",
329
330
  "content": "text",
330
331
  "partOf": "lt-fieldset",
332
+ "texts": [
333
+ {
334
+ "variable": "--lt-label-required-text",
335
+ "text": " (required)",
336
+ "attribute": "data-required",
337
+ "value": "text"
338
+ },
339
+ {
340
+ "variable": "--lt-label-optional-text",
341
+ "text": " (optional)",
342
+ "attribute": "data-optional",
343
+ "unless": [
344
+ "data-required"
345
+ ]
346
+ }
347
+ ],
331
348
  "attributes": {
332
349
  "data-required": {
333
350
  "values": [
@@ -453,6 +470,22 @@ export const classes: Classes = {
453
470
  "description": "A label for form fields, with marking of required and optional. A CSS component.",
454
471
  "link": "https://lo-tek.sobernetics.no/components/label/",
455
472
  "content": "text",
473
+ "texts": [
474
+ {
475
+ "variable": "--lt-label-required-text",
476
+ "text": " (required)",
477
+ "attribute": "data-required",
478
+ "value": "text"
479
+ },
480
+ {
481
+ "variable": "--lt-label-optional-text",
482
+ "text": " (optional)",
483
+ "attribute": "data-optional",
484
+ "unless": [
485
+ "data-required"
486
+ ]
487
+ }
488
+ ],
456
489
  "attributes": {
457
490
  "data-required": {
458
491
  "values": [
@@ -885,6 +918,7 @@ export const classes: Classes = {
885
918
  "multiple": true
886
919
  }
887
920
  },
921
+ "name": {},
888
922
  "attributes": {}
889
923
  },
890
924
  "lt-toggle-group__option": {
@@ -30,7 +30,10 @@ export const elements: Elements = {
30
30
  "description": "The control the field wires: an `<input>`, `<textarea>` or `<select>` with its `<option>`s. `type=\"hidden\"` does not count, and only the first one is wired. The template writes `for` on the label and `id` on the control, or wraps the control in the label.",
31
31
  "content": "none",
32
32
  "unique": true,
33
- "stableId": true
33
+ "stableId": true,
34
+ "name": {
35
+ "after": "label"
36
+ }
34
37
  },
35
38
  "help": {
36
39
  "selector": ".lt-help-text",
@@ -75,7 +78,8 @@ export const elements: Elements = {
75
78
  "selector": "[role='tablist'], .lt-tabs__list",
76
79
  "description": "The tab list, a `<ul>` where each `<li>` gets `role=\"presentation\"`. It needs a name, such as `aria-label`. When the server has written `role=\"tab\"` on the links, the list can also hold other links.",
77
80
  "content": "free",
78
- "unique": true
81
+ "unique": true,
82
+ "name": {}
79
83
  },
80
84
  "tab": {
81
85
  "selector": "a[role='tab'][href^='#'], .lt-tabs__list a[href^='#']:not([role])",
@@ -87,7 +91,10 @@ export const elements: Elements = {
87
91
  "selector": "[role='tabpanel'], .lt-tabs__panel:not([role])",
88
92
  "description": "The content of one tab, with the id the link points to. The panel has the same name as the tab: the heading at the top of the panel is the name when it says the same as the tab.",
89
93
  "content": "free",
90
- "multiple": true
94
+ "multiple": true,
95
+ "name": {
96
+ "component": true
97
+ }
91
98
  }
92
99
  },
93
100
  "events": {
@@ -127,6 +134,10 @@ export const elements: Elements = {
127
134
  "content": "text",
128
135
  "multiple": true
129
136
  }
137
+ },
138
+ "name": {
139
+ "after": "title",
140
+ "id": true
130
141
  }
131
142
  },
132
143
  "lt-popover": {
@@ -220,13 +231,19 @@ export const elements: Elements = {
220
231
  "description": "The field the user types in: `role=\"combobox\"`, otherwise the first `<input>`. The template writes `for` on the label and `id` on the field, or wraps the field in the label.",
221
232
  "content": "none",
222
233
  "unique": true,
223
- "stableId": true
234
+ "stableId": true,
235
+ "name": {
236
+ "after": "label"
237
+ }
224
238
  },
225
239
  "list": {
226
240
  "selector": "[role='listbox'], .lt-suggestion__list",
227
241
  "description": "The list of suggestions. It sits in the top layer when it is open, `popover=\"manual\"`, and has the label as its name.",
228
242
  "content": "parts",
229
- "unique": true
243
+ "unique": true,
244
+ "name": {
245
+ "component": true
246
+ }
230
247
  },
231
248
  "option": {
232
249
  "selector": "[role='option'], .lt-suggestion__list > li:not([role])",
@@ -252,6 +269,34 @@ export const elements: Elements = {
252
269
  "content": "none",
253
270
  "unique": true,
254
271
  "optional": true
272
+ },
273
+ "label": {
274
+ "selector": "label",
275
+ "description": "The label. The component writes `for` on it from the field's id when it does not wrap the field, and names the list after it.",
276
+ "content": "text",
277
+ "optional": true
278
+ }
279
+ },
280
+ "texts": {
281
+ "suggestionNone": {
282
+ "text": "No matches",
283
+ "replacedBy": [
284
+ "count-none",
285
+ "count-other"
286
+ ]
287
+ },
288
+ "suggestionOne": {
289
+ "text": "One match",
290
+ "replacedBy": [
291
+ "count-one",
292
+ "count-other"
293
+ ]
294
+ },
295
+ "suggestionOther": {
296
+ "text": "{n} matches",
297
+ "replacedBy": [
298
+ "count-other"
299
+ ]
255
300
  }
256
301
  },
257
302
  "events": {
@@ -281,7 +326,10 @@ export const elements: Elements = {
281
326
  "selector": ":scope > dialog",
282
327
  "description": "The `<dialog>` element, as a direct child of the host.",
283
328
  "content": "free",
284
- "unique": true
329
+ "unique": true,
330
+ "name": {
331
+ "after": "title"
332
+ }
285
333
  },
286
334
  "title": {
287
335
  "selector": "h1, h2, h3, h4, h5, h6",
@@ -324,6 +372,21 @@ export const elements: Elements = {
324
372
  }
325
373
  },
326
374
  "parts": {},
375
+ "texts": {
376
+ "toastRegion": {
377
+ "text": "Notifications",
378
+ "replacedBy": [
379
+ "label",
380
+ "aria-label"
381
+ ]
382
+ },
383
+ "toastClose": {
384
+ "text": "Close message",
385
+ "replacedBy": [
386
+ "close-label"
387
+ ]
388
+ }
389
+ },
327
390
  "events": {
328
391
  "toast-dismiss": {}
329
392
  }
@@ -350,7 +413,10 @@ export const elements: Elements = {
350
413
  "selector": "dialog",
351
414
  "description": "The `<dialog>` element with the warning.",
352
415
  "content": "free",
353
- "unique": true
416
+ "unique": true,
417
+ "name": {
418
+ "after": "title"
419
+ }
354
420
  },
355
421
  "title": {
356
422
  "selector": "h1, h2, h3, h4, h5, h6",
@@ -404,6 +470,20 @@ export const elements: Elements = {
404
470
  }
405
471
  },
406
472
  "parts": {},
473
+ "texts": {
474
+ "connectionOffline": {
475
+ "text": "No connection. What you type is not being saved.",
476
+ "replacedBy": [
477
+ "offline-text"
478
+ ]
479
+ },
480
+ "connectionOnline": {
481
+ "text": "Connection restored.",
482
+ "replacedBy": [
483
+ "online-text"
484
+ ]
485
+ }
486
+ },
407
487
  "events": {
408
488
  "connection-lost": {},
409
489
  "connection-restored": {}
@@ -34,8 +34,29 @@ export type Part = {
34
34
  optional?: true
35
35
  /** The part must have an id from the server; the component never writes one. */
36
36
  stableId?: true
37
+ /** LO-TEK demands a name for the part. */
38
+ name?: Name
37
39
  }
38
40
 
41
+ /**
42
+ * A name LO-TEK demands where WAI-ARIA does not, or one the component gives
43
+ * itself. WAI-ARIA's own demands, by role, are in the core, and `lotek check`
44
+ * reports an element the markup gives no name (`without-name`).
45
+ *
46
+ * `after` is the part the component points `aria-labelledby` at when the
47
+ * markup has neither `aria-label` nor `aria-labelledby`, such as the heading
48
+ * of a dialog; then that part's name is enough. With `id` the part must have
49
+ * an id from the template, since the component does not write one. With
50
+ * `component` the component names it itself, after what the other rules
51
+ * check, such as the list in a suggestion field after the label, and no
52
+ * rule judges its name in the markup.
53
+ *
54
+ * Each of them is held to the component by a contract in
55
+ * `interfaces/scripts/accessibility/contracts.ts`: a mutation that takes the name away
56
+ * must fail in the browsers, and `lotek check` must report it.
57
+ */
58
+ export type Name = { after?: string; id?: true; component?: true }
59
+
39
60
  /**
40
61
  * An event an element dispatches, such as `dialog-toggle`. `detail` is the
41
62
  * fields in `event.detail` with their types, and is absent when the event
@@ -52,10 +73,35 @@ export type Element = {
52
73
  /** Empty for the elements that create all of their content themselves. */
53
74
  parts?: Record<string, Part>
54
75
  events?: Record<string, ElementEvent>
76
+ /** LO-TEK demands a name for the element itself. */
77
+ name?: Name
78
+ /** The texts the element writes itself, by their name in `DEFAULT_TEXTS`. */
79
+ texts?: Record<string, DefaultText>
55
80
  /** Attributes that have been removed, with what applies instead. */
56
81
  removed?: Record<string, string>
57
82
  }
58
83
 
84
+ /**
85
+ * A text the element writes itself when nobody has given it another, from
86
+ * `DEFAULT_TEXTS`. `replacedBy` is the attributes that replace it; any one
87
+ * of them is enough, and `lotek check` warns when none is there on a page in
88
+ * another language than the text's.
89
+ */
90
+ export type DefaultText = { text: string; replacedBy: readonly string[] }
91
+
92
+ /**
93
+ * A text the stylesheet shows in `::after` on an element with the class and
94
+ * `attribute`, with `value` when the selector requires one, and none of the
95
+ * attributes in `unless`. `variable` is the CSS variable that replaces it.
96
+ */
97
+ export type CssText = {
98
+ variable: string
99
+ text: string
100
+ attribute: string
101
+ value?: string
102
+ unless?: readonly string[]
103
+ }
104
+
59
105
  /** The content of `elements.ts`: tag to element. */
60
106
  export type Elements = Record<string, Element>
61
107
 
@@ -88,6 +134,10 @@ export type ClassInfo = {
88
134
  partOf?: string
89
135
  /** The parts the markup consists of, for the classes with a fixed structure. */
90
136
  parts?: Record<string, Part>
137
+ /** LO-TEK demands a name for the element with the class. */
138
+ name?: Name
139
+ /** The texts the stylesheet shows on it. */
140
+ texts?: readonly CssText[]
91
141
  attributes: Record<string, ClassAttribute>
92
142
  }
93
143
 
@@ -147,6 +197,12 @@ export type Builder = {
147
197
  description?: string
148
198
  }[]
149
199
  returns: BuilderReturns
200
+ /**
201
+ * The classes for the parts the template writes itself, as `lt.card.title`:
202
+ * the name on the function and the class. Only on the builder functions
203
+ * that have one.
204
+ */
205
+ classes?: Record<string, string>
150
206
  }
151
207
 
152
208
  /**
package/web-types.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/JetBrains/web-types/master/schema/web-types.json",
3
3
  "name": "@lo-tek/interfaces",
4
- "version": "0.37.0",
4
+ "version": "0.39.0",
5
5
  "description-markup": "markdown",
6
6
  "contributions": {
7
7
  "html": {