@citisen/litearea 0.1.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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
@@ -0,0 +1,323 @@
1
+ // ─── popup: the completion list ─────────────────────────────────────────────
2
+ //
3
+ // Three parts, and the split between them is the whole design:
4
+ //
5
+ // div.litearea-popup the positioned container; scrolls NOTHING
6
+ // div.litearea-list the scroller, and the listbox role; holds rows only
7
+ // div.litearea-docs the documentation, pinned below the list
8
+ //
9
+ // The documentation used to be appended INSIDE the scrolling element, after the rows, and
10
+ // that made it unreachable in the one case it matters most: with a long list and a long
11
+ // explanation, the explanation sat at the end of the scroll range, so a keyboard user could
12
+ // never see it at all — the arrows move the active row, not the scrollbar — and a mouse user
13
+ // had to scroll down to read it and then back up to reach the next row. Pinning it outside the
14
+ // scroller fixes both, because the panel never moves when the list scrolls and never has to be
15
+ // scrolled to in the first place.
16
+ //
17
+ // Two interaction details are also deliberate. A pick must not blur the field: a click fires
18
+ // `mousedown` before `blur`, so the row listens for `mousedown`, cancels the default to keep
19
+ // the focus, and accepts — listening for `click` instead means the field is already blurred by
20
+ // the time the handler runs. And the list scrolls only as far as the active row needs, because
21
+ // a list that re-centres on every arrow press looks like it is jumping.
22
+
23
+ import type { CompletionRow, SuggestionItem } from '../core/types.js'
24
+ import { highlightSegments } from '../core/rank.js'
25
+
26
+ /** Where a floating element is allowed to sit. */
27
+ export interface AnchorBox {
28
+ /** Distance from the positioning container's left edge. */
29
+ x: number
30
+ /** Distance from the positioning container's top edge. */
31
+ y: number
32
+ /** The height of the line the anchor sits on. */
33
+ height: number
34
+ }
35
+
36
+ /** What the list tells its owner. */
37
+ export interface PopupHandlers {
38
+ /** A row was chosen with the mouse. */
39
+ accept(index: number): void
40
+ /** The pointer moved over a row, which makes it active. */
41
+ hover(index: number): void
42
+ }
43
+
44
+ /** The completion list, with its documentation panel. */
45
+ export class Popup {
46
+ /** The positioned container. */
47
+ readonly element: HTMLDivElement
48
+ /** The scrolling element, which holds the rows and nothing else. */
49
+ readonly list: HTMLDivElement
50
+ private readonly document: Document
51
+ private readonly handlers: PopupHandlers
52
+ private rows: readonly CompletionRow[] = []
53
+ private active = -1
54
+ private open = false
55
+ private showDocs = true
56
+ private readonly docs: HTMLDivElement
57
+ private readonly prefix: string
58
+
59
+ /**
60
+ * @param ownerDocument - the document to build in.
61
+ * @param handlers - how to report a pick and a hover.
62
+ * @param idPrefix - a stable prefix for row ids, so two editors do not collide.
63
+ */
64
+ constructor(ownerDocument: Document, handlers: PopupHandlers, idPrefix: string) {
65
+ this.document = ownerDocument
66
+ this.handlers = handlers
67
+ this.prefix = idPrefix
68
+
69
+ this.element = ownerDocument.createElement('div')
70
+ this.element.className = 'litearea-popup'
71
+ this.element.dataset.liteareaPart = 'popup'
72
+ this.element.dataset.open = 'false'
73
+
74
+ this.list = ownerDocument.createElement('div')
75
+ this.list.className = 'litearea-list'
76
+ this.list.setAttribute('role', 'listbox')
77
+ this.list.id = `${idPrefix}-listbox`
78
+ this.element.appendChild(this.list)
79
+
80
+ this.docs = ownerDocument.createElement('div')
81
+ this.docs.className = 'litearea-docs'
82
+ this.docs.hidden = true
83
+ this.element.appendChild(this.docs)
84
+
85
+ // Bound to the list rather than to each row, so replacing every row on each keystroke does
86
+ // not mean attaching and detaching listeners on every keystroke.
87
+ this.list.addEventListener('mousedown', this.onMouseDown)
88
+ this.list.addEventListener('mousemove', this.onMouseMove)
89
+ }
90
+
91
+ /** Whether the list is showing. */
92
+ get isOpen(): boolean {
93
+ return this.open
94
+ }
95
+
96
+ /** The active row's index, or -1. */
97
+ get activeIndex(): number {
98
+ return this.active
99
+ }
100
+
101
+ /** The rows currently shown. */
102
+ get items(): readonly CompletionRow[] {
103
+ return this.rows
104
+ }
105
+
106
+ /** The active row, when there is one. */
107
+ get activeRow(): CompletionRow | undefined {
108
+ return this.rows[this.active]
109
+ }
110
+
111
+ /** The id the field's `aria-controls` should name. It is the list, not the container. */
112
+ get listId(): string {
113
+ return this.list.id
114
+ }
115
+
116
+ /**
117
+ * The id of the active row, for the field's `aria-activedescendant`.
118
+ *
119
+ * The list does not write that attribute itself: the field belongs to the editor, and a
120
+ * floating list reaching out of its own subtree to find it is the kind of coupling that
121
+ * breaks the moment the two are mounted somewhere unexpected.
122
+ * @returns the row id, or undefined when no row is active.
123
+ */
124
+ get activeRowId(): string | undefined {
125
+ return this.active >= 0 ? this.rowId(this.active) : undefined
126
+ }
127
+
128
+ /**
129
+ * Show a list.
130
+ * @param rows - the rows, already ranked.
131
+ * @param active - the row to make active.
132
+ * @param showDocs - whether to render the documentation panel.
133
+ */
134
+ show(rows: readonly CompletionRow[], active: number, showDocs: boolean): void {
135
+ this.rows = rows
136
+ this.active = active
137
+ this.showDocs = showDocs
138
+ this.open = true
139
+ this.element.dataset.open = 'true'
140
+ this.element.dataset.docs = showDocs ? 'true' : 'false'
141
+ this.list.scrollTop = 0
142
+ this.render()
143
+ }
144
+
145
+ /** Hide the list and forget its rows. */
146
+ close(): void {
147
+ if (!this.open) return
148
+ this.open = false
149
+ this.active = -1
150
+ this.rows = []
151
+ this.element.dataset.open = 'false'
152
+ this.list.replaceChildren()
153
+ this.docs.hidden = true
154
+ this.docs.replaceChildren()
155
+ }
156
+
157
+ /**
158
+ * Make a row active without rebuilding the list.
159
+ * @param index - the row index.
160
+ */
161
+ setActive(index: number): void {
162
+ if (index === this.active) return
163
+ const previous = this.list.querySelector<HTMLElement>(`#${this.rowId(this.active)}`)
164
+ if (previous !== null) previous.setAttribute('aria-selected', 'false')
165
+ this.active = index
166
+ const next = this.list.querySelector<HTMLElement>(`#${this.rowId(index)}`)
167
+ if (next !== null) {
168
+ next.setAttribute('aria-selected', 'true')
169
+ // Only as far as needed, and only the LIST scrolls: the documentation panel is outside
170
+ // this element, so moving the active row can never push the explanation out of sight.
171
+ const top = next.offsetTop
172
+ const bottom = top + next.offsetHeight
173
+ if (top < this.list.scrollTop) this.list.scrollTop = top
174
+ else if (bottom > this.list.scrollTop + this.list.clientHeight) {
175
+ this.list.scrollTop = bottom - this.list.clientHeight
176
+ }
177
+ }
178
+ this.renderDocs()
179
+ }
180
+
181
+ /**
182
+ * Place the list under an anchor, flipping above it when there is no room below.
183
+ *
184
+ * The container's own box is what is measured, so the documentation panel counts towards the
185
+ * height and a popup whose rows and explanation together would run off the bottom flips as a
186
+ * whole rather than being cut in half.
187
+ * @param anchor - the caret's box, in the container's coordinates.
188
+ * @param container - the element the list is positioned against.
189
+ * @param viewport - the visible area to stay inside.
190
+ */
191
+ place(anchor: AnchorBox, container: HTMLElement, viewport: { width: number; height: number }): void {
192
+ const box = this.element.getBoundingClientRect()
193
+ const containerBox = container.getBoundingClientRect()
194
+ const gap = 4
195
+ let top = anchor.y + anchor.height + gap
196
+ if (containerBox.top + top + box.height > viewport.height - gap) {
197
+ const above = anchor.y - box.height - gap
198
+ // Flipping only helps if there is somewhere to flip TO; otherwise the list stays below
199
+ // and is clamped by the max-heights in the stylesheet.
200
+ if (containerBox.top + above >= gap) top = above
201
+ }
202
+ let left = anchor.x
203
+ const maxLeft = containerBox.width - box.width
204
+ if (left > maxLeft) left = Math.max(0, maxLeft)
205
+ this.element.style.top = `${String(Math.round(top))}px`
206
+ this.element.style.left = `${String(Math.round(left))}px`
207
+ }
208
+
209
+ /** Take the list out of the document. */
210
+ destroy(): void {
211
+ this.list.removeEventListener('mousedown', this.onMouseDown)
212
+ this.list.removeEventListener('mousemove', this.onMouseMove)
213
+ this.element.remove()
214
+ this.list.replaceChildren()
215
+ this.docs.replaceChildren()
216
+ }
217
+
218
+ /** The DOM id of a row, which `aria-activedescendant` points at. */
219
+ rowId(index: number): string {
220
+ return `${this.prefix}-row-${String(index)}`
221
+ }
222
+
223
+ // ── internals ────────────────────────────────────────────────────────────
224
+
225
+ private render(): void {
226
+ const fragment = this.document.createDocumentFragment()
227
+ this.rows.forEach((row, index) => {
228
+ const element = this.document.createElement('div')
229
+ element.className = 'litearea-row'
230
+ element.id = this.rowId(index)
231
+ element.setAttribute('role', 'option')
232
+ element.setAttribute('aria-selected', index === this.active ? 'true' : 'false')
233
+ element.dataset.index = String(index)
234
+
235
+ const kind = this.document.createElement('span')
236
+ kind.className = `litearea-rowKind litearea-kind-${row.item.kind ?? 'value'}`
237
+ element.appendChild(kind)
238
+
239
+ const label = this.document.createElement('span')
240
+ label.className = 'litearea-rowLabel'
241
+ // Emphasised as segments rather than as markup: a font family may legitimately be called
242
+ // `<b>`, and a list that built HTML from a label would either render it or have to escape
243
+ // it, and both are wrong for text that came from the user's machine.
244
+ for (const piece of highlightSegments(row.item.label, row.indices)) {
245
+ if (piece.matched) {
246
+ const mark = this.document.createElement('span')
247
+ mark.className = 'litearea-rowMatch'
248
+ mark.textContent = piece.text
249
+ label.appendChild(mark)
250
+ } else {
251
+ label.appendChild(this.document.createTextNode(piece.text))
252
+ }
253
+ }
254
+ element.appendChild(label)
255
+
256
+ if (row.item.detail !== undefined) {
257
+ const detail = this.document.createElement('span')
258
+ detail.className = 'litearea-rowDetail'
259
+ detail.textContent = row.item.detail
260
+ element.appendChild(detail)
261
+ }
262
+ fragment.appendChild(element)
263
+ })
264
+
265
+ // Rows go in the list and nowhere else. The documentation is a sibling that is never
266
+ // rebuilt here, so converting a long explanation cannot disturb the scroll position.
267
+ this.list.replaceChildren(fragment)
268
+ this.renderDocs()
269
+ }
270
+
271
+ /** Refresh the documentation panel from the active row. */
272
+ private renderDocs(): void {
273
+ const item: SuggestionItem | undefined = this.rows[this.active]?.item
274
+ const hasDocs =
275
+ this.showDocs &&
276
+ this.open &&
277
+ item !== undefined &&
278
+ (item.documentation !== undefined || item.detail !== undefined)
279
+ this.docs.hidden = !hasDocs
280
+ if (!hasDocs || item === undefined) {
281
+ this.docs.replaceChildren()
282
+ return
283
+ }
284
+ const fragment = this.document.createDocumentFragment()
285
+ const title = this.document.createElement('div')
286
+ title.className = 'litearea-docsTitle'
287
+ title.textContent = item.label
288
+ fragment.appendChild(title)
289
+ if (item.detail !== undefined) {
290
+ const detail = this.document.createElement('div')
291
+ detail.className = 'litearea-docsDetail'
292
+ detail.textContent = item.detail
293
+ fragment.appendChild(detail)
294
+ }
295
+ if (item.documentation !== undefined) {
296
+ const body = this.document.createElement('div')
297
+ body.className = 'litearea-docsBody'
298
+ body.textContent = item.documentation
299
+ fragment.appendChild(body)
300
+ }
301
+ this.docs.replaceChildren(fragment)
302
+ // A new explanation is read from its beginning, which matters when the previous one was
303
+ // long enough to have been scrolled.
304
+ this.docs.scrollTop = 0
305
+ }
306
+
307
+ private readonly onMouseDown = (event: MouseEvent): void => {
308
+ const row = (event.target as HTMLElement | null)?.closest<HTMLElement>('.litearea-row')
309
+ if (row === null || row === undefined) return
310
+ // Before blur, and cancelling the default is what keeps the field focused.
311
+ event.preventDefault()
312
+ const index = Number.parseInt(row.dataset.index ?? '-1', 10)
313
+ if (Number.isInteger(index) && index >= 0) this.handlers.accept(index)
314
+ }
315
+
316
+ private readonly onMouseMove = (event: MouseEvent): void => {
317
+ const row = (event.target as HTMLElement | null)?.closest<HTMLElement>('.litearea-row')
318
+ if (row === null || row === undefined) return
319
+ const index = Number.parseInt(row.dataset.index ?? '-1', 10)
320
+ if (!Number.isInteger(index) || index < 0 || index === this.active) return
321
+ this.handlers.hover(index)
322
+ }
323
+ }
@@ -0,0 +1,88 @@
1
+ // ─── support: what this browser can actually do ─────────────────────────────
2
+ //
3
+ // The engine is pure and needs nothing. This module is the only place that asks
4
+ // the environment a question, so the answer is in one place and the rest of the
5
+ // DOM layer can assume it.
6
+
7
+ /** Whether a document is available at all, so an import is safe under Node. */
8
+ export function hasDocument(): boolean {
9
+ return typeof document !== 'undefined' && document !== null
10
+ }
11
+
12
+ /**
13
+ * Whether the browser still offers `document.execCommand`.
14
+ *
15
+ * It is deprecated, and it is also the only way to change a textarea's value while
16
+ * keeping the browser's own undo stack intact — no modern input API can be driven
17
+ * from script, because a synthetic event is untrusted and the browser refuses to
18
+ * treat it as a user edit. So the check is not "is this nice API available" but
19
+ * "can this edit be undone", and the editor degrades honestly when the answer is
20
+ * no: the edit still lands, and the history it should have joined does not.
21
+ * @returns whether an undo-preserving edit is possible.
22
+ */
23
+ export function canEditThroughPipeline(): boolean {
24
+ return hasDocument() && typeof document.execCommand === 'function'
25
+ }
26
+
27
+ /** Whether `caretPositionFromPoint` or its WebKit spelling exists. */
28
+ export function hasCaretHitTest(): boolean {
29
+ if (!hasDocument()) return false
30
+ const probe = document as Document & {
31
+ caretPositionFromPoint?: unknown
32
+ caretRangeFromPoint?: unknown
33
+ }
34
+ return (
35
+ typeof probe.caretPositionFromPoint === 'function' ||
36
+ typeof probe.caretRangeFromPoint === 'function'
37
+ )
38
+ }
39
+
40
+ /**
41
+ * The character offset a point in the viewport falls on, according to the browser.
42
+ *
43
+ * The browser is asked rather than the editor computing it, because hit-testing
44
+ * wrapped, proportional text is exactly the kind of geometry a hand-written
45
+ * measurement gets subtly wrong. A textarea's transparent text still lays out
46
+ * normally, so the caret APIs answer for it correctly.
47
+ * @param x - viewport x.
48
+ * @param y - viewport y.
49
+ * @returns the offset, or undefined when the browser cannot say.
50
+ */
51
+ export function offsetFromPoint(x: number, y: number): number | undefined {
52
+ if (!hasDocument()) return undefined
53
+ const probe = document as Document & {
54
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
55
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
56
+ }
57
+ if (typeof probe.caretPositionFromPoint === 'function') {
58
+ const position = probe.caretPositionFromPoint(x, y)
59
+ if (position !== null && position !== undefined) return position.offset
60
+ return undefined
61
+ }
62
+ if (typeof probe.caretRangeFromPoint === 'function') {
63
+ const range = probe.caretRangeFromPoint(x, y)
64
+ if (range !== null && range !== undefined) return range.startOffset
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ /**
70
+ * Merge partial option objects without letting an explicit `undefined` win.
71
+ *
72
+ * `{ ...defaults, ...given }` looks right and is wrong the moment a caller passes
73
+ * an optional field it did not fill in: the key exists, so it overwrites the
74
+ * default with `undefined`, and the editor then behaves as if the option had been
75
+ * switched off. This copies only the keys that were actually provided.
76
+ * @param defaults - the base.
77
+ * @param given - the overrides, which may be undefined.
78
+ * @returns the merged object.
79
+ */
80
+ export function withDefaults<T extends object>(defaults: T, given: Partial<T> | undefined): T {
81
+ if (given === undefined) return { ...defaults }
82
+ const merged = { ...defaults }
83
+ for (const key of Object.keys(given) as Array<keyof T>) {
84
+ const value = given[key]
85
+ if (value !== undefined) merged[key] = value as T[keyof T]
86
+ }
87
+ return merged
88
+ }
@@ -0,0 +1,112 @@
1
+ // ─── tooltip: what the pointer is resting on ────────────────────────────────
2
+ //
3
+ // One floating element, positioned near the thing it describes and kept inside the
4
+ // viewport. It never takes the pointer: a tooltip that can be hovered is a tooltip
5
+ // that flickers, because moving the mouse onto it leaves the thing that opened it.
6
+ //
7
+ // The content is deliberately plain text, not markup. A grammar supplies strings,
8
+ // those strings may be a font family called `<b>` or a value containing `#`, and a
9
+ // tooltip that rendered them as HTML would either mis-render the text or have to
10
+ // escape it — both wrong for text the user typed.
11
+
12
+ import type { HoverInfo } from '../core/types.js'
13
+
14
+ /** Where a floating element is allowed to sit. */
15
+ export interface TooltipAnchor {
16
+ /** Distance from the positioning container's left edge. */
17
+ x: number
18
+ /** Distance from the positioning container's top edge. */
19
+ y: number
20
+ /** The height of the anchored line or range. */
21
+ height: number
22
+ }
23
+
24
+ /** The hover tooltip. */
25
+ export class Tooltip {
26
+ /** The tooltip element. */
27
+ readonly element: HTMLDivElement
28
+ private readonly document: Document
29
+ private open = false
30
+
31
+ /**
32
+ * @param ownerDocument - the document to build in.
33
+ */
34
+ constructor(ownerDocument: Document) {
35
+ this.document = ownerDocument
36
+ this.element = ownerDocument.createElement('div')
37
+ this.element.className = 'litearea-tooltip'
38
+ this.element.setAttribute('role', 'tooltip')
39
+ this.element.dataset.liteareaPart = 'tooltip'
40
+ this.element.dataset.open = 'false'
41
+ }
42
+
43
+ /** Whether the tooltip is showing. */
44
+ get isOpen(): boolean {
45
+ return this.open
46
+ }
47
+
48
+ /**
49
+ * Show a hover.
50
+ * @param info - what to say.
51
+ * @param anchor - where the thing being described is.
52
+ * @param container - the element the tooltip is positioned against.
53
+ * @param viewport - the visible area to stay inside.
54
+ */
55
+ show(
56
+ info: HoverInfo,
57
+ anchor: TooltipAnchor,
58
+ container: HTMLElement,
59
+ viewport: { width: number; height: number },
60
+ ): void {
61
+ const fragment = this.document.createDocumentFragment()
62
+ if (info.title !== undefined) {
63
+ const title = this.document.createElement('div')
64
+ title.className = 'litearea-tooltipTitle'
65
+ title.textContent = info.title
66
+ fragment.appendChild(title)
67
+ }
68
+ if (info.detail !== undefined && info.detail !== '') {
69
+ const detail = this.document.createElement('div')
70
+ detail.className = 'litearea-tooltipDetail'
71
+ detail.textContent = info.detail
72
+ fragment.appendChild(detail)
73
+ }
74
+ if (info.body !== undefined && info.body !== '') {
75
+ const body = this.document.createElement('div')
76
+ body.className = 'litearea-tooltipBody'
77
+ body.textContent = info.body
78
+ fragment.appendChild(body)
79
+ }
80
+ this.element.replaceChildren(fragment)
81
+ this.open = true
82
+ this.element.dataset.open = 'true'
83
+
84
+ const box = this.element.getBoundingClientRect()
85
+ const containerBox = container.getBoundingClientRect()
86
+ const gap = 6
87
+ let top = anchor.y + anchor.height + gap
88
+ if (containerBox.top + top + box.height > viewport.height - gap) {
89
+ const above = anchor.y - box.height - gap
90
+ top = containerBox.top + above >= gap ? above : Math.max(0, viewport.height - gap - box.height - containerBox.top)
91
+ }
92
+ let left = anchor.x
93
+ const overflowRight = containerBox.left + left + box.width - (viewport.width - gap)
94
+ if (overflowRight > 0) left = Math.max(0, left - overflowRight)
95
+ this.element.style.top = `${String(Math.round(top))}px`
96
+ this.element.style.left = `${String(Math.round(left))}px`
97
+ }
98
+
99
+ /** Hide the tooltip. */
100
+ hide(): void {
101
+ if (!this.open) return
102
+ this.open = false
103
+ this.element.dataset.open = 'false'
104
+ this.element.replaceChildren()
105
+ }
106
+
107
+ /** Take the tooltip out of the document. */
108
+ destroy(): void {
109
+ this.element.remove()
110
+ this.element.replaceChildren()
111
+ }
112
+ }