@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.
- package/CHANGELOG.md +160 -2
- package/README.md +4 -3
- package/agent/astro.md +1 -1
- package/agent/bundles.md +4 -2
- package/agent/datastar.md +48 -48
- package/agent/html.md +51 -51
- package/agent/react.md +6 -5
- 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 +52 -0
- package/dist/components/host-element.d.ts.map +1 -1
- package/dist/components/host-element.js +164 -1
- 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 +60 -0
- package/dist/vocabulary/types.d.ts.map +1 -1
- package/live-templates.xml +3 -2
- package/manifest/manifest.json +172 -8
- package/manifest/manifest.schema.json +80 -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 +190 -1
- 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 +56 -0
- package/web-types.json +1 -1
|
@@ -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. */
|
|
@@ -270,11 +270,30 @@ export class LtSessionTimeout extends HostElement {
|
|
|
270
270
|
* dialog, without the component having to hold on to a particular node.
|
|
271
271
|
*/
|
|
272
272
|
this.addEventListener("close", this.handleClose, true)
|
|
273
|
-
this.openObserver = new MutationObserver(() =>
|
|
273
|
+
this.openObserver = new MutationObserver(() => {
|
|
274
|
+
this.repairOpen()
|
|
275
|
+
/*
|
|
276
|
+
* A morph of the open warning takes the role and the name the component
|
|
277
|
+
* wrote, since the renderer did not send them, and the screen reader had
|
|
278
|
+
* a modal dialog without a name until the warning closed. They are
|
|
279
|
+
* written again while it shows. Such a morph also removes `open`, which
|
|
280
|
+
* is enough to get here; the wiring's own attributes are watched for a
|
|
281
|
+
* morph that leaves `open` alone.
|
|
282
|
+
*/
|
|
283
|
+
const dialog = this.dialog
|
|
284
|
+
if (dialog?.matches(":modal")) this.wire(dialog)
|
|
285
|
+
})
|
|
274
286
|
this.openObserver.observe(this, {
|
|
275
287
|
subtree: true,
|
|
276
288
|
attributes: true,
|
|
277
|
-
attributeFilter: [
|
|
289
|
+
attributeFilter: [
|
|
290
|
+
"open",
|
|
291
|
+
"role",
|
|
292
|
+
"class",
|
|
293
|
+
"id",
|
|
294
|
+
"aria-labelledby",
|
|
295
|
+
"aria-label",
|
|
296
|
+
],
|
|
278
297
|
})
|
|
279
298
|
this.ticker = window.setInterval(() => this.tick(), 1000)
|
|
280
299
|
this.validate()
|
|
@@ -509,7 +528,16 @@ export class LtSessionTimeout extends HostElement {
|
|
|
509
528
|
// dialog has been replaced in the meantime, it never comes, and then the
|
|
510
529
|
// memory is released.
|
|
511
530
|
const current = this.dialog
|
|
531
|
+
/*
|
|
532
|
+
* The renderer replaced the dialog while the warning showed, as
|
|
533
|
+
* `innerHTML` does: still open when it went, so replaced, not closed.
|
|
534
|
+
* Decided in the tick that opens the new one, below; kept in a field, it
|
|
535
|
+
* outlived a `reset()` in between, and the next warning, minutes later,
|
|
536
|
+
* sent no `session-warn`.
|
|
537
|
+
*/
|
|
538
|
+
let continuing = false
|
|
512
539
|
if (this.shownDialog && this.shownDialog !== current) {
|
|
540
|
+
continuing = this.shownDialog.open
|
|
513
541
|
this.shownDialog = undefined
|
|
514
542
|
}
|
|
515
543
|
if (this.shownDialog && !this.shownDialog.open) return
|
|
@@ -532,7 +560,7 @@ export class LtSessionTimeout extends HostElement {
|
|
|
532
560
|
if (elapsed < this.warnAt && !dialog?.open) return
|
|
533
561
|
|
|
534
562
|
if (!dialog?.open) {
|
|
535
|
-
this.openDialog(left)
|
|
563
|
+
this.openDialog(left, continuing)
|
|
536
564
|
return
|
|
537
565
|
}
|
|
538
566
|
/*
|
|
@@ -577,7 +605,7 @@ export class LtSessionTimeout extends HostElement {
|
|
|
577
605
|
setText(live, sentence.replace(/\s+/g, " ").trim())
|
|
578
606
|
}
|
|
579
607
|
|
|
580
|
-
private openDialog(left: number): void {
|
|
608
|
+
private openDialog(left: number, continuing = false): void {
|
|
581
609
|
const dialog = this.dialog
|
|
582
610
|
if (!dialog) {
|
|
583
611
|
// By now the page has long since settled, so an empty element is not a
|
|
@@ -606,7 +634,13 @@ export class LtSessionTimeout extends HostElement {
|
|
|
606
634
|
// field is in the page, as `<lt-error-summary>` also warns about. The field
|
|
607
635
|
// can be on either side of the shadow boundary from the component, so only
|
|
608
636
|
// the descent finds it.
|
|
609
|
-
|
|
637
|
+
/*
|
|
638
|
+
* A warning that was showing when the renderer replaced the dialog, as
|
|
639
|
+
* `innerHTML` does, never closed: the user is still being warned. Focus
|
|
640
|
+
* goes back to where it was before that warning, not to `<body>`, where
|
|
641
|
+
* it is now, and `session-warn` has been sent already.
|
|
642
|
+
*/
|
|
643
|
+
if (!continuing) this.previousFocus = deepActiveElement()
|
|
610
644
|
/*
|
|
611
645
|
* The text must be in place before the dialog opens, since `alertdialog` is
|
|
612
646
|
* announced as it appears. The number in the paragraph is `aria-hidden`, so
|
|
@@ -623,7 +657,7 @@ export class LtSessionTimeout extends HostElement {
|
|
|
623
657
|
dialog.returnValue = ""
|
|
624
658
|
dialog.showModal()
|
|
625
659
|
this.shownDialog = dialog
|
|
626
|
-
this.emit("session-warn")
|
|
660
|
+
if (!continuing) this.emit("session-warn")
|
|
627
661
|
}
|
|
628
662
|
|
|
629
663
|
private closeDialog(reason: string): void {
|