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