@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.
- package/CHANGELOG.md +137 -2
- package/README.md +4 -3
- package/agent/astro.md +1 -1
- package/agent/bundles.md +1 -1
- package/agent/datastar.md +48 -48
- package/agent/html.md +51 -51
- package/agent/react.md +1 -1
- package/agent/templates.md +49 -49
- package/core/lotek-core.wasm +0 -0
- package/core/lotek.wasm +0 -0
- package/dist/components/default-texts.d.ts +6 -0
- package/dist/components/default-texts.d.ts.map +1 -1
- package/dist/components/default-texts.js +6 -0
- package/dist/components/default-texts.js.map +1 -1
- package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -1
- package/dist/components/frame/dialog/lt-dialog.js +5 -2
- package/dist/components/frame/dialog/lt-dialog.js.map +1 -1
- package/dist/components/frame/error-summary/error-summary.d.ts +15 -2
- package/dist/components/frame/error-summary/error-summary.d.ts.map +1 -1
- package/dist/components/frame/error-summary/error-summary.js +33 -11
- package/dist/components/frame/error-summary/error-summary.js.map +1 -1
- package/dist/components/frame/error-summary/lt-error-summary.d.ts +11 -0
- package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -1
- package/dist/components/frame/error-summary/lt-error-summary.js +114 -8
- package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -1
- package/dist/components/frame/field/lt-field.d.ts.map +1 -1
- package/dist/components/frame/field/lt-field.js +34 -14
- package/dist/components/frame/field/lt-field.js.map +1 -1
- package/dist/components/frame/popover/lt-popover.d.ts +6 -2
- package/dist/components/frame/popover/lt-popover.d.ts.map +1 -1
- package/dist/components/frame/popover/lt-popover.js +44 -14
- package/dist/components/frame/popover/lt-popover.js.map +1 -1
- package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -1
- package/dist/components/frame/session-timeout/lt-session-timeout.js +43 -6
- package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -1
- package/dist/components/frame/suggestion/lt-suggestion.d.ts +8 -2
- package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -1
- package/dist/components/frame/suggestion/lt-suggestion.js +56 -37
- package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -1
- package/dist/components/frame/tabs/lt-tabs.d.ts +32 -10
- package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -1
- package/dist/components/frame/tabs/lt-tabs.js +85 -21
- package/dist/components/frame/tabs/lt-tabs.js.map +1 -1
- package/dist/components/host-element.d.ts +49 -0
- package/dist/components/host-element.d.ts.map +1 -1
- package/dist/components/host-element.js +74 -0
- package/dist/components/host-element.js.map +1 -1
- package/dist/components/standalone/connection-status/connection-status.d.ts +5 -1
- package/dist/components/standalone/connection-status/connection-status.d.ts.map +1 -1
- package/dist/components/standalone/connection-status/connection-status.js +5 -1
- package/dist/components/standalone/connection-status/connection-status.js.map +1 -1
- package/dist/components/standalone/connection-status/lt-connection-status.d.ts +11 -6
- package/dist/components/standalone/connection-status/lt-connection-status.d.ts.map +1 -1
- package/dist/components/standalone/connection-status/lt-connection-status.js +34 -10
- package/dist/components/standalone/connection-status/lt-connection-status.js.map +1 -1
- package/dist/diagnostics/core.d.ts +34 -0
- package/dist/diagnostics/core.d.ts.map +1 -1
- package/dist/diagnostics/core.js +14 -2
- package/dist/diagnostics/core.js.map +1 -1
- package/dist/lotek.css +1 -1
- package/dist/lt.d.ts +1 -1
- package/dist/react.d.ts +2 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/vocabulary/classes.d.ts.map +1 -1
- package/dist/vocabulary/classes.js +34 -0
- package/dist/vocabulary/classes.js.map +1 -1
- package/dist/vocabulary/elements.d.ts.map +1 -1
- package/dist/vocabulary/elements.js +87 -7
- package/dist/vocabulary/elements.js.map +1 -1
- package/dist/vocabulary/types.d.ts +54 -0
- package/dist/vocabulary/types.d.ts.map +1 -1
- package/live-templates.xml +3 -2
- package/manifest/manifest.json +133 -8
- package/manifest/manifest.schema.json +73 -0
- package/package.json +2 -1
- package/src/components/default-texts.ts +7 -0
- package/src/components/frame/dialog/lt-dialog.ts +5 -2
- package/src/components/frame/error-summary/error-summary.ts +45 -11
- package/src/components/frame/error-summary/lt-error-summary.ts +121 -7
- package/src/components/frame/field/lt-field.ts +65 -28
- package/src/components/frame/popover/lt-popover.ts +43 -12
- package/src/components/frame/session-timeout/lt-session-timeout.ts +40 -6
- package/src/components/frame/suggestion/lt-suggestion.ts +57 -36
- package/src/components/frame/tabs/lt-tabs.ts +114 -38
- package/src/components/host-element.ts +93 -0
- package/src/components/standalone/connection-status/connection-status.ts +7 -1
- package/src/components/standalone/connection-status/lt-connection-status.ts +36 -10
- package/src/diagnostics/core.ts +19 -2
- package/src/vocabulary/classes.ts +34 -0
- package/src/vocabulary/elements.ts +87 -7
- package/src/vocabulary/types.ts +50 -0
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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)
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
now.describedBy !== known.written.describedBy
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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:
|
|
721
|
-
describedBy:
|
|
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
|
-
|
|
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
|
|
212
|
-
if (this.wantsOpen && !this.open)
|
|
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(
|
|
336
|
+
setAttr(trigger, "aria-expanded", String(shown))
|
|
327
337
|
|
|
328
|
-
if (
|
|
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"))
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|