@lo-tek/interfaces 0.40.0 → 0.42.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 (82) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agent/astro.md +9 -7
  3. package/agent/bundles.md +8 -6
  4. package/agent/datastar.md +60 -51
  5. package/agent/html.md +55 -53
  6. package/agent/kotlin.md +622 -0
  7. package/agent/react.md +8 -6
  8. package/agent/templates.md +60 -63
  9. package/core/lotek-core.wasm +0 -0
  10. package/core/lotek.wasm +0 -0
  11. package/dist/components/css/steps/steps.d.ts +23 -0
  12. package/dist/components/css/steps/steps.d.ts.map +1 -0
  13. package/dist/components/css/steps/steps.js +21 -0
  14. package/dist/components/css/steps/steps.js.map +1 -0
  15. package/dist/components/events.d.ts +3 -1
  16. package/dist/components/events.d.ts.map +1 -1
  17. package/dist/components/frame/error-summary/lt-error-summary.js +1 -1
  18. package/dist/components/frame/popover/lt-popover.d.ts.map +1 -1
  19. package/dist/components/frame/popover/lt-popover.js +4 -19
  20. package/dist/components/frame/popover/lt-popover.js.map +1 -1
  21. package/dist/components/frame/term/lt-term.d.ts +114 -0
  22. package/dist/components/frame/term/lt-term.d.ts.map +1 -0
  23. package/dist/components/frame/term/lt-term.js +530 -0
  24. package/dist/components/frame/term/lt-term.js.map +1 -0
  25. package/dist/components/frame/term/term.d.ts +55 -0
  26. package/dist/components/frame/term/term.d.ts.map +1 -0
  27. package/dist/components/frame/term/term.js +55 -0
  28. package/dist/components/frame/term/term.js.map +1 -0
  29. package/dist/components/host-element.d.ts +19 -0
  30. package/dist/components/host-element.d.ts.map +1 -1
  31. package/dist/components/host-element.js +37 -0
  32. package/dist/components/host-element.js.map +1 -1
  33. package/dist/index.d.ts +4 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/jsx/react.d.ts +3 -1
  38. package/dist/jsx/react.d.ts.map +1 -1
  39. package/dist/lotek.css +351 -1
  40. package/dist/lt.d.ts +8 -1
  41. package/dist/lt.d.ts.map +1 -1
  42. package/dist/lt.js +9 -1
  43. package/dist/lt.js.map +1 -1
  44. package/dist/react.d.ts +19 -0
  45. package/dist/react.d.ts.map +1 -1
  46. package/dist/react.js +14 -0
  47. package/dist/react.js.map +1 -1
  48. package/dist/register.d.ts.map +1 -1
  49. package/dist/register.js +2 -0
  50. package/dist/register.js.map +1 -1
  51. package/dist/vocabulary/classes.d.ts.map +1 -1
  52. package/dist/vocabulary/classes.js +60 -6
  53. package/dist/vocabulary/classes.js.map +1 -1
  54. package/dist/vocabulary/elements.d.ts.map +1 -1
  55. package/dist/vocabulary/elements.js +15 -2
  56. package/dist/vocabulary/elements.js.map +1 -1
  57. package/dist/vocabulary/types.d.ts +8 -3
  58. package/dist/vocabulary/types.d.ts.map +1 -1
  59. package/live-templates-kotlin.xml +15 -12
  60. package/live-templates.xml +12 -6
  61. package/manifest/files.json +18 -0
  62. package/manifest/manifest.json +145 -10
  63. package/package.json +15 -5
  64. package/src/components/css/steps/steps.css +240 -0
  65. package/src/components/css/steps/steps.ts +26 -0
  66. package/src/components/events.ts +3 -1
  67. package/src/components/frame/error-summary/lt-error-summary.ts +1 -1
  68. package/src/components/frame/popover/lt-popover.ts +4 -24
  69. package/src/components/frame/term/lt-term.ts +573 -0
  70. package/src/components/frame/term/term.css +177 -0
  71. package/src/components/frame/term/term.ts +87 -0
  72. package/src/components/host-element.ts +46 -0
  73. package/src/index.ts +4 -0
  74. package/src/jsx/react.ts +4 -0
  75. package/src/lt.ts +9 -1
  76. package/src/react.ts +15 -0
  77. package/src/register.ts +2 -0
  78. package/src/tokens/tokens.css +4 -0
  79. package/src/vocabulary/classes.ts +60 -6
  80. package/src/vocabulary/elements.ts +15 -2
  81. package/src/vocabulary/types.ts +8 -3
  82. package/web-types.json +31 -10
@@ -0,0 +1,573 @@
1
+ import {
2
+ addClass,
3
+ defineElement,
4
+ HostElement,
5
+ place,
6
+ setAttr,
7
+ warnAboutMarkup,
8
+ } from "../../host-element.js"
9
+ import { TERM_CLASS, TERM_ENTRY_CLASS } from "./term.js"
10
+
11
+ export const LT_TERM_TAG = "lt-term" as const
12
+
13
+ /**
14
+ * The parts the web component finds, as selectors relative to the element.
15
+ * The entry is not among them: it is in the glossary, somewhere else on the
16
+ * page, and is found by the id the link points to.
17
+ */
18
+ export const LT_TERM_PARTS = {
19
+ word: "a[href^='#']",
20
+ } as const
21
+
22
+ /** How long the mouse rests on the word before the card opens. */
23
+ const OPEN_DELAY = 300
24
+ /**
25
+ * How long the card waits for the mouse after it leaves the word or the
26
+ * card. The two are separate boxes, one of them in the top layer, so the
27
+ * pointer leaves the one before it reaches the other.
28
+ */
29
+ const CLOSE_DELAY = 200
30
+
31
+ /**
32
+ * Which `<lt-term>` shows an entry right now. Several words can explain
33
+ * themselves with the same entry, and the entry is one element, so only one
34
+ * of them can have it open. The one that opens takes it from the other.
35
+ */
36
+ const shownBy = new WeakMap<Element, LtTerm>()
37
+
38
+ /**
39
+ * An entry is a `<div>` in a `<dl>` with a `<dt>` and a `<dd>`: the form HTML
40
+ * allows for grouping a term with its description. A `<dt>` alone cannot be
41
+ * the card, since the description is a sibling of it, not a child.
42
+ */
43
+ function isEntry(element: Element): boolean {
44
+ return (
45
+ element.localName === "div" &&
46
+ element.parentElement?.localName === "dl" &&
47
+ element.querySelector(":scope > dt") !== null &&
48
+ element.querySelector(":scope > dd") !== null
49
+ )
50
+ }
51
+
52
+ /**
53
+ * The ids the link's `href` can mean, as the browser tries them: the
54
+ * fragment as written, then decoded. Empty when there is no fragment.
55
+ */
56
+ function fragmentsOf(word: Element): string[] {
57
+ const href = word.getAttribute("href") ?? ""
58
+ if (!href.startsWith("#") || href.length < 2) return []
59
+ const raw = href.slice(1)
60
+ try {
61
+ const decoded = decodeURIComponent(raw)
62
+ return decoded === raw ? [raw] : [raw, decoded]
63
+ } catch {
64
+ return [raw]
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The `aria-details` the component wrote, per link. A value that is still
70
+ * there is the component's to change when `href` points elsewhere; any
71
+ * other value is the server's word, and stays.
72
+ */
73
+ const detailsWritten = new WeakMap<Element, string>()
74
+
75
+ type Shown = "no" | "hover" | "pinned"
76
+
77
+ /**
78
+ * A word in the text that shows its explanation from a glossary on the same
79
+ * page, in a card by the word.
80
+ *
81
+ * The markup is a link to the entry, and that is all it is without
82
+ * JavaScript: a click goes to the glossary. The web component makes the
83
+ * entry itself the card. It sets `popover="manual"` on the entry while the
84
+ * card is open, so the entry goes into the top layer by the word, and takes
85
+ * it off again when it closes, so the entry is back in its place in the
86
+ * glossary. Nothing is copied: the text is sent once, the links in it are
87
+ * the real ones, and the component writes no id, since the server wrote the
88
+ * one the link points to.
89
+ *
90
+ * The mouse opens the card after a short rest on the word, and the card stays
91
+ * while the pointer is on the word or in the card. A click, a tap or Enter
92
+ * opens it and pins it, so it stays until Escape, a click outside, another
93
+ * click on the word, or the word scrolling out of view. A link clicked with a
94
+ * modifier key is left to the browser.
95
+ *
96
+ * The component writes `aria-details` on the link when it is missing, and
97
+ * `aria-expanded` from whether the card is open. It puts both back when a
98
+ * patch removes them, and while the card is open it also puts back what it
99
+ * set on the entry.
100
+ *
101
+ * ```html
102
+ * <p>Datastar keeps <lt-term><a class="lt-term" href="#term-signal">signals</a></lt-term> in the browser.</p>
103
+ * <dl>
104
+ * <div id="term-signal"><dt>Signal</dt><dd>A value Datastar keeps in the browser.</dd></div>
105
+ * </dl>
106
+ * ```
107
+ */
108
+ export class LtTerm extends HostElement {
109
+ static observedAttributes = [] as const
110
+
111
+ private word?: HTMLAnchorElement
112
+ /** The entry the link points to, from the last round, or `null`. */
113
+ private target: HTMLElement | null = null
114
+ /** The entry this component shows as the card right now. */
115
+ private entry?: HTMLElement
116
+ private shown: Shown = "no"
117
+ private observer?: MutationObserver
118
+ /** Watches the entry while the card is open: a patch can strip what the component set on it. */
119
+ private outside?: MutationObserver
120
+ private visibility?: IntersectionObserver
121
+ private openTimer?: ReturnType<typeof setTimeout>
122
+ private closeTimer?: ReturnType<typeof setTimeout>
123
+ /** What the component put on the entry, to take off again when it closes. */
124
+ private addedPopover = false
125
+ private addedClass = false
126
+
127
+ /**
128
+ * One observer per document or shadow root, shared by every `<lt-term>`
129
+ * in it. The entry is in the glossary, outside the element, and a patch
130
+ * there can give it its id, take it, or replace it: the word must then
131
+ * say again whether it expands. An observer per word on the whole page
132
+ * would be one per word in the text.
133
+ */
134
+ private static readonly roots = new WeakMap<
135
+ Node,
136
+ { observer: MutationObserver; terms: Set<LtTerm> }
137
+ >()
138
+ /** The root this element was registered with, to leave it again. */
139
+ private watched?: Node
140
+
141
+ private static watch(term: LtTerm): void {
142
+ const root = term.getRootNode()
143
+ let entry = LtTerm.roots.get(root)
144
+ if (!entry) {
145
+ const terms = new Set<LtTerm>()
146
+ const observer = new MutationObserver((records) => {
147
+ // Text alone, such as a countdown or a filtered list, cannot make
148
+ // or take an entry: only an element or an id can.
149
+ const elements = (nodes: NodeList) =>
150
+ [...nodes].some((n) => n.nodeType === Node.ELEMENT_NODE)
151
+ const matters = records.some(
152
+ (r) =>
153
+ r.type === "attributes" ||
154
+ elements(r.addedNodes) ||
155
+ elements(r.removedNodes),
156
+ )
157
+ if (matters) for (const t of terms) t.sync()
158
+ })
159
+ // Only nodes and ids: everything the component writes is something
160
+ // else, so its own writes never call it.
161
+ observer.observe(root, {
162
+ childList: true,
163
+ subtree: true,
164
+ attributes: true,
165
+ attributeFilter: ["id"],
166
+ })
167
+ entry = { observer, terms }
168
+ LtTerm.roots.set(root, entry)
169
+ }
170
+ entry.terms.add(term)
171
+ term.watched = root
172
+ }
173
+
174
+ private static unwatch(term: LtTerm): void {
175
+ if (!term.watched) return
176
+ const entry = LtTerm.roots.get(term.watched)
177
+ entry?.terms.delete(term)
178
+ if (entry && entry.terms.size === 0) {
179
+ entry.observer.disconnect()
180
+ LtTerm.roots.delete(term.watched)
181
+ }
182
+ term.watched = undefined
183
+ }
184
+
185
+ connectedCallback(): void {
186
+ this.observer = new MutationObserver(() => this.sync())
187
+ this.observer.observe(this, {
188
+ childList: true,
189
+ subtree: true,
190
+ attributes: true,
191
+ // `class` too: the component sets `lt-term` on a link without it, and
192
+ // a morph takes it again.
193
+ attributeFilter: ["href", "class", "aria-details", "aria-expanded"],
194
+ })
195
+ LtTerm.watch(this)
196
+ this.sync()
197
+ }
198
+
199
+ disconnectedCallback(): void {
200
+ this.close()
201
+ this.observer?.disconnect()
202
+ this.observer = undefined
203
+ LtTerm.unwatch(this)
204
+ this.detachWord()
205
+ }
206
+
207
+ /**
208
+ * Whether the card is open, by the mouse or pinned. A method and not a
209
+ * getter: React 19 sets a property with the same name as an attribute,
210
+ * and a getter without a setter throws.
211
+ */
212
+ private isOpen(): boolean {
213
+ return this.shown !== "no"
214
+ }
215
+
216
+ private root(): Document | ShadowRoot {
217
+ return this.getRootNode() as Document | ShadowRoot
218
+ }
219
+
220
+ /** The link that belongs to this element, not to an `<lt-term>` inside it. */
221
+ private findWord(): HTMLAnchorElement | null {
222
+ return (
223
+ [...this.querySelectorAll<HTMLAnchorElement>(LT_TERM_PARTS.word)].find(
224
+ (word) => word.closest(this.localName) === this,
225
+ ) ?? null
226
+ )
227
+ }
228
+
229
+ private findTarget(word: Element): HTMLElement | null {
230
+ const root = this.root()
231
+ for (const id of fragmentsOf(word)) {
232
+ const found = root.getElementById?.(id)
233
+ if (found) return found
234
+ }
235
+ return null
236
+ }
237
+
238
+ private sync(): void {
239
+ const word = this.findWord()
240
+ if (!word) {
241
+ warnAboutMarkup(
242
+ this,
243
+ 'found no link: an <a> with an href to the glossary entry, such as href="#term-signal". Without it there is no word to explain.',
244
+ () => this.childElementCount > 0 && this.findWord() === null,
245
+ )
246
+ this.close()
247
+ this.detachWord()
248
+ return
249
+ }
250
+ if (this.word !== word) {
251
+ this.detachWord()
252
+ word.addEventListener("click", this.handleClick)
253
+ word.addEventListener("pointerenter", this.handlePointerEnter)
254
+ word.addEventListener("pointerleave", this.handlePointerLeave)
255
+ this.word = word
256
+ // An open card follows the new link out of view, not the removed one,
257
+ // which counts as out of view at once.
258
+ if (this.visibility) {
259
+ this.visibility.disconnect()
260
+ this.visibility.observe(word)
261
+ }
262
+ }
263
+ addClass(word, TERM_CLASS)
264
+
265
+ const found = this.findTarget(word)
266
+ const entry = found && isEntry(found) ? found : null
267
+ this.target = entry
268
+
269
+ if (!found) {
270
+ warnAboutMarkup(
271
+ this,
272
+ "the link points to an id the page does not have. The word stays a plain link; give the glossary entry the id in href.",
273
+ () => {
274
+ const now = this.findWord()
275
+ return now !== null && this.findTarget(now) === null
276
+ },
277
+ )
278
+ } else if (!entry) {
279
+ warnAboutMarkup(
280
+ this,
281
+ "the link points to something that is not a glossary entry: a <div> in a <dl> with a <dt> and a <dd>. The word stays a plain link; put the id on the <div>.",
282
+ () => {
283
+ const now = this.findWord()
284
+ const target = now ? this.findTarget(now) : null
285
+ return target !== null && !isEntry(target)
286
+ },
287
+ )
288
+ }
289
+
290
+ // `aria-details` the server wrote is its word, also when it points
291
+ // elsewhere. What the component wrote follows the entry, and goes with it.
292
+ const details = word.getAttribute("aria-details")
293
+ if (details === null || details === detailsWritten.get(word)) {
294
+ setAttr(word, "aria-details", entry?.id ?? null)
295
+ if (entry) detailsWritten.set(word, entry.id)
296
+ else detailsWritten.delete(word)
297
+ }
298
+
299
+ if (!entry) {
300
+ // Not an entry, so nothing expands. The link still goes where it points.
301
+ setAttr(word, "aria-expanded", null)
302
+ if (this.entry) this.close()
303
+ return
304
+ }
305
+
306
+ setAttr(word, "aria-expanded", String(this.isOpen()))
307
+
308
+ if (!this.isOpen()) return
309
+ if (this.entry !== entry) {
310
+ // A patch replaced the entry while it was open. The new one is the
311
+ // card now; the old one is gone, and took the top layer with it.
312
+ if (this.entry) this.release(this.entry)
313
+ this.display(entry)
314
+ } else {
315
+ // The same entry, perhaps stripped of what the component set on it.
316
+ this.display(entry)
317
+ }
318
+ }
319
+
320
+ private detachWord(): void {
321
+ this.word?.removeEventListener("click", this.handleClick)
322
+ this.word?.removeEventListener("pointerenter", this.handlePointerEnter)
323
+ this.word?.removeEventListener("pointerleave", this.handlePointerLeave)
324
+ this.word = undefined
325
+ }
326
+
327
+ /** Opens the card, by the mouse or pinned. */
328
+ private show(mode: Exclude<Shown, "no">): void {
329
+ this.clearTimers()
330
+ const entry = this.target
331
+ if (!entry || !this.word) return
332
+ const other = shownBy.get(entry)
333
+ if (other && other !== this) other.close()
334
+ this.shown = mode
335
+ this.display(entry)
336
+ setAttr(this.word, "aria-expanded", "true")
337
+ }
338
+
339
+ /** Puts the entry in the top layer by the word, and keeps it there. */
340
+ private display(entry: HTMLElement): void {
341
+ if (this.entry !== entry) {
342
+ this.entry = entry
343
+ this.addedPopover = false
344
+ this.addedClass = false
345
+ entry.addEventListener("pointerenter", this.handleCardEnter)
346
+ entry.addEventListener("pointerleave", this.handleCardLeave)
347
+ }
348
+ shownBy.set(entry, this)
349
+
350
+ if (!entry.hasAttribute("popover")) {
351
+ setAttr(entry, "popover", "manual")
352
+ this.addedPopover = true
353
+ }
354
+ if (!entry.classList.contains(TERM_ENTRY_CLASS)) {
355
+ addClass(entry, TERM_ENTRY_CLASS)
356
+ this.addedClass = true
357
+ }
358
+ if (!entry.matches(":popover-open")) entry.showPopover()
359
+ this.reposition()
360
+
361
+ if (!this.outside) {
362
+ this.outside = new MutationObserver(() => this.sync())
363
+ document.addEventListener("click", this.handleOutsideClick, true)
364
+ // In the capture phase: a card in an open popover is closer than the
365
+ // popover, and closes first. The popover leaves a used press alone.
366
+ document.addEventListener("keydown", this.handleKeydown, true)
367
+ window.addEventListener("resize", this.reposition)
368
+ window.addEventListener("scroll", this.reposition, true)
369
+ this.visibility = new IntersectionObserver((records) => {
370
+ // Out of view: the reader has moved on. Scrolling alone does not
371
+ // close the card, so a reader who scrolls a little keeps it.
372
+ if (records.some((record) => !record.isIntersecting)) this.close()
373
+ })
374
+ if (this.word) this.visibility.observe(this.word)
375
+ }
376
+ // Observed again for every entry, which may be a new node. A replaced
377
+ // entry is seen by the observer on the root.
378
+ this.outside.observe(entry, {
379
+ attributes: true,
380
+ attributeFilter: ["popover", "class", "style", "id"],
381
+ })
382
+ }
383
+
384
+ /** Takes the entry out of the top layer and back to its place in the glossary. */
385
+ private release(entry: HTMLElement): void {
386
+ entry.removeEventListener("pointerenter", this.handleCardEnter)
387
+ entry.removeEventListener("pointerleave", this.handleCardLeave)
388
+ if (shownBy.get(entry) === this) shownBy.delete(entry)
389
+ if (entry.matches(":popover-open")) entry.hidePopover()
390
+ if (this.addedPopover) setAttr(entry, "popover", null)
391
+ if (this.addedClass && entry.classList.contains(TERM_ENTRY_CLASS)) {
392
+ const rest = [...entry.classList]
393
+ .filter((name) => name !== TERM_ENTRY_CLASS)
394
+ .join(" ")
395
+ setAttr(entry, "class", rest === "" ? null : rest)
396
+ }
397
+ entry.style.removeProperty("--lt-term-top")
398
+ entry.style.removeProperty("--lt-term-left")
399
+ if (entry.getAttribute("style") === "") setAttr(entry, "style", null)
400
+ this.addedPopover = false
401
+ this.addedClass = false
402
+ }
403
+
404
+ /** Closes the card, and moves focus to the word if it was in the card. */
405
+ private close(): void {
406
+ this.clearTimers()
407
+ const entry = this.entry
408
+ const hadFocus = entry?.contains(this.root().activeElement ?? null) ?? false
409
+ this.shown = "no"
410
+ this.entry = undefined
411
+ this.outside?.disconnect()
412
+ this.outside = undefined
413
+ this.visibility?.disconnect()
414
+ this.visibility = undefined
415
+ document.removeEventListener("click", this.handleOutsideClick, true)
416
+ document.removeEventListener("keydown", this.handleKeydown, true)
417
+ window.removeEventListener("resize", this.reposition)
418
+ window.removeEventListener("scroll", this.reposition, true)
419
+ if (entry) this.release(entry)
420
+ if (this.word && this.target) setAttr(this.word, "aria-expanded", "false")
421
+ if (hadFocus) this.word?.focus({ preventScroll: true })
422
+ }
423
+
424
+ private clearTimers(): void {
425
+ clearTimeout(this.openTimer)
426
+ clearTimeout(this.closeTimer)
427
+ this.openTimer = undefined
428
+ this.closeTimer = undefined
429
+ }
430
+
431
+ private closeSoon(): void {
432
+ clearTimeout(this.closeTimer)
433
+ this.closeTimer = setTimeout(() => {
434
+ if (this.shown === "hover") this.close()
435
+ }, CLOSE_DELAY)
436
+ }
437
+
438
+ private handleClick = (event: MouseEvent): void => {
439
+ if (event.defaultPrevented || event.button !== 0) return
440
+ // A new tab, a new window, a download: the reader wants the link.
441
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
442
+ if (!this.target) return
443
+ // Not a jump to the glossary, and the address stays as it is.
444
+ event.preventDefault()
445
+ if (this.shown === "pinned") this.close()
446
+ else this.show("pinned")
447
+ }
448
+
449
+ /*
450
+ * Only the mouse. A tap gives `pointerenter` with `pointerType` "touch"
451
+ * before the click, and opening on it would have the click close the card
452
+ * again, or pin one the reader never saw being opened by hover.
453
+ */
454
+ private handlePointerEnter = (event: PointerEvent): void => {
455
+ if (event.pointerType !== "mouse") return
456
+ clearTimeout(this.closeTimer)
457
+ if (this.shown !== "no" || !this.target) return
458
+ clearTimeout(this.openTimer)
459
+ this.openTimer = setTimeout(() => {
460
+ if (this.shown === "no") this.show("hover")
461
+ }, OPEN_DELAY)
462
+ }
463
+
464
+ private handlePointerLeave = (event: PointerEvent): void => {
465
+ if (event.pointerType !== "mouse") return
466
+ clearTimeout(this.openTimer)
467
+ if (this.shown === "hover") this.closeSoon()
468
+ }
469
+
470
+ // The pointer in the card keeps it, so the reader can select text and
471
+ // follow the links in it.
472
+ private handleCardEnter = (event: PointerEvent): void => {
473
+ if (event.pointerType === "mouse") clearTimeout(this.closeTimer)
474
+ }
475
+
476
+ private handleCardLeave = (event: PointerEvent): void => {
477
+ if (event.pointerType === "mouse" && this.shown === "hover")
478
+ this.closeSoon()
479
+ }
480
+
481
+ private handleOutsideClick = (event: Event): void => {
482
+ // `composedPath()` and not `target`, which is retargeted to the shadow
483
+ // host when the component is in a shadow root.
484
+ const target = event.composedPath()[0]
485
+ if (!(target instanceof Node)) return
486
+ if (this.word?.contains(target) || this.entry?.contains(target)) return
487
+ this.close()
488
+ }
489
+
490
+ private handleKeydown = (event: KeyboardEvent): void => {
491
+ if (event.key !== "Escape" || !this.isOpen() || event.defaultPrevented)
492
+ return
493
+ // A modal dialog opened over the card closes on its own press.
494
+ for (const node of event.composedPath()) {
495
+ if (node === this) break
496
+ if (
497
+ node instanceof HTMLDialogElement &&
498
+ node.matches(":modal") &&
499
+ !node.contains(this)
500
+ ) {
501
+ return
502
+ }
503
+ }
504
+ event.preventDefault()
505
+ // From the path and not `activeElement`, which a shadow root answers
506
+ // with null when the focus is outside it. Back to the word when the
507
+ // focus was on it, in the card, or nowhere: on `<body>`, or on a dialog
508
+ // or a `<main>` the word is in. From a field elsewhere it stays.
509
+ const target = event.composedPath()[0]
510
+ const back =
511
+ target instanceof Node &&
512
+ (target === this.word ||
513
+ (this.entry?.contains(target) ?? false) ||
514
+ target.contains(this))
515
+ this.close()
516
+ // Focus goes back to the word, without scrolling the page to it.
517
+ if (back) this.word?.focus({ preventScroll: true })
518
+ }
519
+
520
+ /**
521
+ * Under the word, or over it when there is no room. A word that wraps
522
+ * over two lines has two boxes, and the card goes under the last.
523
+ *
524
+ * On a narrow screen the stylesheet makes the card a sheet across the
525
+ * window, and says so with `--lt-term-sheet`, which is the stylesheet's
526
+ * to set and not the page's. The sheet goes at the bottom, unless it
527
+ * would cover the word: then at the top.
528
+ */
529
+ private reposition = (): void => {
530
+ const entry = this.entry
531
+ const word = this.word
532
+ if (!entry || !word) return
533
+ const boxes = word.getClientRects()
534
+ const anchor = boxes[boxes.length - 1] ?? word.getBoundingClientRect()
535
+ const card = entry.getBoundingClientRect()
536
+ const style = getComputedStyle(entry)
537
+
538
+ let top: number
539
+ let left: number
540
+ if (style.getPropertyValue("--lt-term-sheet").trim() === "1") {
541
+ // At the bottom when the word is clear of it, otherwise at the top
542
+ // when the word is clear of that. A word in the middle under a tall
543
+ // sheet is covered either way, and the sheet goes on the side away
544
+ // from it.
545
+ const bottom = document.documentElement.clientHeight - card.height
546
+ if (anchor.bottom <= bottom) top = bottom
547
+ else if (anchor.top >= card.height) top = 0
548
+ else
549
+ top =
550
+ anchor.top + anchor.height / 2 >
551
+ document.documentElement.clientHeight / 2
552
+ ? 0
553
+ : bottom
554
+ left = 0
555
+ } else {
556
+ // The space under the word is `--lt-term-gap`, which the stylesheet
557
+ // puts in `scroll-margin-block-start`: there the browser has worked
558
+ // it out in pixels, whatever unit the page gave it.
559
+ const gap = Number.parseFloat(style.scrollMarginBlockStart) || 0
560
+ const rtl = word.matches(":dir(rtl)")
561
+ ;({ top, left } = place(anchor, card, true, rtl, gap))
562
+ }
563
+ top = Math.round(top)
564
+ if (entry.style.getPropertyValue("--lt-term-top") !== `${top}px`)
565
+ entry.style.setProperty("--lt-term-top", `${top}px`)
566
+ if (entry.style.getPropertyValue("--lt-term-left") !== `${left}px`)
567
+ entry.style.setProperty("--lt-term-left", `${left}px`)
568
+ }
569
+ }
570
+
571
+ export function defineLtTerm(tagName: string = LT_TERM_TAG): void {
572
+ defineElement(tagName, LtTerm)
573
+ }