@api-client/ui 0.5.7 → 0.5.9

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 (27) hide show
  1. package/build/src/elements/mention-textarea/internals/MentionTextArea.d.ts +225 -0
  2. package/build/src/elements/mention-textarea/internals/MentionTextArea.d.ts.map +1 -0
  3. package/build/src/elements/mention-textarea/internals/MentionTextArea.js +1065 -0
  4. package/build/src/elements/mention-textarea/internals/MentionTextArea.js.map +1 -0
  5. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.d.ts +3 -0
  6. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.d.ts.map +1 -0
  7. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.js +274 -0
  8. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.js.map +1 -0
  9. package/build/src/elements/mention-textarea/ui-mention-textarea.d.ts +13 -0
  10. package/build/src/elements/mention-textarea/ui-mention-textarea.d.ts.map +1 -0
  11. package/build/src/elements/mention-textarea/ui-mention-textarea.js +28 -0
  12. package/build/src/elements/mention-textarea/ui-mention-textarea.js.map +1 -0
  13. package/build/src/md/chip/internals/Chip.styles.d.ts.map +1 -1
  14. package/build/src/md/chip/internals/Chip.styles.js +2 -0
  15. package/build/src/md/chip/internals/Chip.styles.js.map +1 -1
  16. package/demo/elements/index.html +3 -0
  17. package/demo/elements/mention-textarea/index.html +19 -0
  18. package/demo/elements/mention-textarea/index.ts +205 -0
  19. package/package.json +2 -2
  20. package/src/elements/mention-textarea/internals/MentionTextArea.styles.ts +274 -0
  21. package/src/elements/mention-textarea/internals/MentionTextArea.ts +1068 -0
  22. package/src/elements/mention-textarea/ui-mention-textarea.ts +18 -0
  23. package/src/md/chip/internals/Chip.styles.ts +2 -0
  24. package/test/elements/http/CertificateAdd.test.ts +0 -3
  25. package/test/elements/mention-textarea/MentionTextArea.basic.test.ts +114 -0
  26. package/test/elements/mention-textarea/MentionTextArea.test.ts +613 -0
  27. package/tsconfig.json +1 -1
@@ -0,0 +1,1068 @@
1
+ import { html, LitElement, PropertyValues, TemplateResult, nothing } from 'lit'
2
+ import { property, query, state } from 'lit/decorators.js'
3
+ import { classMap } from 'lit/directives/class-map.js'
4
+ import { ifDefined } from 'lit/directives/if-defined.js'
5
+ import reactive from '../../../decorators/reactive.js'
6
+ import '../../../md/chip/ui-chip.js'
7
+
8
+ export interface MentionSuggestion {
9
+ /** Unique identifier for the suggestion */
10
+ id: string
11
+ /** Main label/headline displayed */
12
+ label: string
13
+ /** Supporting description text */
14
+ description?: string
15
+ /** Suffix text (e.g., role, department) */
16
+ suffix?: string
17
+ /** Additional data associated with the suggestion */
18
+ data?: Record<string, unknown>
19
+ }
20
+
21
+ export interface MentionInsertEvent {
22
+ /** The inserted mention suggestion */
23
+ suggestion: MentionSuggestion
24
+ /** The text that triggered the mention (e.g., "@john") */
25
+ trigger: string
26
+ /** The position where the mention was inserted */
27
+ position: number
28
+ }
29
+
30
+ export interface MentionRemoveEvent {
31
+ /** The removed mention suggestion */
32
+ suggestion: MentionSuggestion
33
+ /** The position where the mention was removed from */
34
+ position: number
35
+ }
36
+
37
+ interface Fragment {
38
+ type: 'text' | 'mention'
39
+ content: string
40
+ mentionId?: string
41
+ }
42
+
43
+ /**
44
+ * A NodeFilter mask that includes text nodes and elements.
45
+ * It is used with the NodeWalker
46
+ */
47
+ const ShowMask = NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
48
+
49
+ /**
50
+ * A material design textarea component that supports @mentions with suggestions.
51
+ *
52
+ * Features:
53
+ * - Material Design styling
54
+ * - @mention triggers with customizable suggestions
55
+ * - Inline pill/chip rendering for mentions
56
+ * - Proper caret management
57
+ * - Keyboard navigation for suggestions
58
+ * - Overflow container support
59
+ * - Generic design for extensibility
60
+ * - Accessibility support
61
+ *
62
+ * @fires mention-insert - When a mention is inserted
63
+ * @fires mention-remove - When a mention is removed
64
+ * @fires input - When the input value changes
65
+ * @fires change - When the input loses focus and value has changed
66
+ */
67
+ export default class MentionTextArea extends LitElement {
68
+ /**
69
+ * Shadow root configuration for the component.
70
+ * Uses 'open' mode for accessibility and delegates focus to enable proper focus management.
71
+ */
72
+ static override shadowRootOptions: ShadowRootInit = {
73
+ mode: 'open',
74
+ delegatesFocus: true,
75
+ }
76
+
77
+ /**
78
+ * The label text displayed as placeholder/floating label
79
+ */
80
+ @property({ type: String })
81
+ accessor label = ''
82
+
83
+ /**
84
+ * Supporting text displayed below the input
85
+ */
86
+ @property({ type: String, attribute: 'supporting-text' })
87
+ accessor supportingText = ''
88
+
89
+ /**
90
+ * Whether the component is disabled
91
+ */
92
+ @property({ type: Boolean, reflect: true })
93
+ accessor disabled = false
94
+
95
+ /**
96
+ * Whether the component is in an invalid state
97
+ */
98
+ @property({ type: Boolean, reflect: true })
99
+ accessor invalid = false
100
+
101
+ /**
102
+ * The name attribute for form integration
103
+ */
104
+ @property({ type: String })
105
+ accessor name = ''
106
+
107
+ /**
108
+ * Whether the input is required
109
+ */
110
+ @property({ type: Boolean })
111
+ accessor required = false
112
+
113
+ /**
114
+ * Placeholder text shown when input is empty
115
+ */
116
+ @property({ type: String })
117
+ accessor placeholder = ''
118
+
119
+ get value(): string {
120
+ return this._value
121
+ }
122
+
123
+ @property({ type: String })
124
+ set value(newValue: string) {
125
+ const oldValue = this._value
126
+ if (newValue === this._value) return // No change, skip update
127
+ this._value = newValue
128
+ this.requestUpdate('value', oldValue)
129
+ // Only sync editor from value when set externally and element is ready
130
+ if (this.editorElement) {
131
+ this.syncEditorFromValue()
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Available suggestions for mentions
137
+ */
138
+ @property({ type: Array, attribute: false })
139
+ accessor suggestions: MentionSuggestion[] = []
140
+
141
+ /**
142
+ * Character that triggers mention suggestions (default: '@')
143
+ */
144
+ @property({ type: String, attribute: 'mention-trigger' })
145
+ accessor mentionTrigger = '@'
146
+
147
+ /**
148
+ * Minimum characters after trigger to show suggestions
149
+ */
150
+ @property({ type: Number, attribute: 'min-query-length' })
151
+ accessor minQueryLength = 0
152
+
153
+ @query('.editor')
154
+ private accessor editorElement!: HTMLDivElement
155
+
156
+ @query('.suggestions-popover')
157
+ private accessor suggestionsPopover!: HTMLElement
158
+
159
+ @state()
160
+ @reactive()
161
+ private accessor isShowingSuggestions = false
162
+
163
+ @reactive()
164
+ private accessor filteredSuggestions: MentionSuggestion[] = []
165
+
166
+ @state()
167
+ private accessor selectedSuggestionIndex = 0
168
+
169
+ @state()
170
+ private accessor hasContent = false
171
+
172
+ @state()
173
+ private accessor isLabelFloating = false
174
+
175
+ @state()
176
+ private accessor isEditorFocus = false
177
+
178
+ private currentMentionQuery = ''
179
+ private currentMentionStart = -1
180
+ private mentionMap = new Map<string, MentionSuggestion>()
181
+ private mutationObserver?: MutationObserver
182
+ private _value = '' // Internal value storage
183
+
184
+ /**
185
+ * A read-only list of the names of the fields currently mentioned in the textarea.
186
+ */
187
+ get mentions(): string[] {
188
+ const dependencies = new Set<string>()
189
+ if (!this.value) {
190
+ return []
191
+ }
192
+ const regex = /@\{([^}]+)\}/g
193
+ let match: RegExpExecArray | null
194
+ while ((match = regex.exec(this.value)) !== null) {
195
+ dependencies.add(match[1])
196
+ }
197
+ return Array.from(dependencies)
198
+ }
199
+
200
+ override connectedCallback(): void {
201
+ super.connectedCallback()
202
+
203
+ // Setup mutation observer to watch for changes in the editor
204
+ this.mutationObserver = new MutationObserver(() => {
205
+ this.syncValueFromEditor()
206
+ })
207
+ }
208
+
209
+ override disconnectedCallback(): void {
210
+ super.disconnectedCallback()
211
+ this.mutationObserver?.disconnect()
212
+ }
213
+
214
+ override firstUpdated(): void {
215
+ // Start observing mutations in the editor
216
+ if (this.editorElement && this.mutationObserver) {
217
+ this.mutationObserver.observe(this.editorElement, {
218
+ childList: true,
219
+ subtree: true,
220
+ characterData: true,
221
+ })
222
+ }
223
+
224
+ // Setup initial content
225
+ this.updateComplete.then(() => {
226
+ this.syncEditorFromValue()
227
+ })
228
+ }
229
+
230
+ override willUpdate(changedProperties: PropertyValues): void {
231
+ super.willUpdate(changedProperties)
232
+
233
+ // No need to sync editor from value here since setter handles it
234
+
235
+ if (changedProperties.has('suggestions')) {
236
+ this.updateFilteredSuggestions()
237
+ }
238
+ if (changedProperties.has('selectedSuggestionIndex')) {
239
+ const index = this.selectedSuggestionIndex
240
+ if (index >= 0) {
241
+ this.suggestionsPopover?.querySelector(`.suggestion-item:nth-child(${index + 1})`)?.scrollIntoView({
242
+ behavior: 'smooth',
243
+ block: 'nearest',
244
+ })
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Get the plain text content without field references
251
+ * @returns The plain text content with field names instead of references
252
+ */
253
+ public getPlainText(): string {
254
+ let text = this.value
255
+
256
+ // Replace field references with their display names
257
+ text = text.replace(/@\{([^}]+)\}/g, (match, fieldId) => {
258
+ const field = this.suggestions.find((f) => f.id === fieldId)
259
+ return field ? field.label : fieldId
260
+ })
261
+
262
+ return text
263
+ }
264
+
265
+ /**
266
+ * Sets the caret position at the beginning of a specific text node
267
+ */
268
+ private setCaretPositionAtTextNode(textNode: Text, offset = 0): void {
269
+ const selection = window.getSelection()
270
+ if (!selection) return
271
+
272
+ const range = document.createRange()
273
+ range.setStart(textNode, offset)
274
+ range.setEnd(textNode, offset)
275
+
276
+ selection.removeAllRanges()
277
+ selection.addRange(range)
278
+ }
279
+
280
+ /**
281
+ * Synchronizes the value property from the editor content
282
+ */
283
+ private syncValueFromEditor(): void {
284
+ const newValue = this.getValueFromEditor()
285
+ if (newValue !== this._value) {
286
+ // Update internal value directly to avoid triggering setter
287
+ this._value = newValue
288
+ this.requestUpdate('value')
289
+ this.dispatchEvent(new Event('input', { bubbles: true }))
290
+ }
291
+
292
+ this.updateContentState()
293
+ }
294
+
295
+ /**
296
+ * Gets the value from editor content, converting chips to @{mention} format
297
+ */
298
+ private getValueFromEditor(): string {
299
+ const childNodes = Array.from(this.editorElement.childNodes)
300
+ let result = ''
301
+
302
+ for (const node of childNodes) {
303
+ if (node.nodeType === Node.TEXT_NODE) {
304
+ result += node.textContent || ''
305
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
306
+ const element = node as Element
307
+ if (element.classList.contains('mention-chip')) {
308
+ const mentionId = element.getAttribute('data-mention-id')
309
+ if (mentionId) {
310
+ result += `@{${mentionId}}`
311
+ }
312
+ } else {
313
+ result += element.textContent || ''
314
+ }
315
+ }
316
+ }
317
+
318
+ return result
319
+ }
320
+
321
+ /**
322
+ * Synchronizes the editor content from the value property
323
+ */
324
+ private syncEditorFromValue(): void {
325
+ if (!this.editorElement) return
326
+
327
+ const fragments = this.parseValueToFragments(this._value)
328
+ this.editorElement.innerHTML = ''
329
+
330
+ for (const fragment of fragments) {
331
+ if (fragment.type === 'text') {
332
+ this.editorElement.appendChild(document.createTextNode(fragment.content))
333
+ } else if (fragment.type === 'mention') {
334
+ if (fragment.mentionId) {
335
+ let mention = this.mentionMap.get(fragment.mentionId)
336
+
337
+ // If mention not in map, try to find it in suggestions
338
+ if (!mention) {
339
+ mention = this.suggestions.find((s) => s.id === fragment.mentionId)
340
+ if (mention) {
341
+ // Add to map for future use
342
+ this.mentionMap.set(mention.id, mention)
343
+ }
344
+ }
345
+
346
+ if (mention) {
347
+ const chipElement = this.createMentionChip(mention)
348
+ this.editorElement.appendChild(chipElement)
349
+ } else {
350
+ // Fallback to text if mention not found anywhere
351
+ this.editorElement.appendChild(document.createTextNode(`@{${fragment.mentionId}}`))
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ this.updateContentState()
358
+ }
359
+
360
+ /**
361
+ * Parses value string into text and mention fragments
362
+ */
363
+ private parseValueToFragments(value: string): Fragment[] {
364
+ const fragments: Fragment[] = []
365
+ const mentionRegex = /@\{([^}]+)\}/g
366
+ let lastIndex = 0
367
+ let match
368
+
369
+ while ((match = mentionRegex.exec(value)) !== null) {
370
+ // Add text before mention
371
+ if (match.index > lastIndex) {
372
+ fragments.push({
373
+ type: 'text',
374
+ content: value.substring(lastIndex, match.index),
375
+ })
376
+ }
377
+
378
+ // Add mention
379
+ fragments.push({
380
+ type: 'mention',
381
+ content: match[0],
382
+ mentionId: match[1],
383
+ })
384
+
385
+ lastIndex = mentionRegex.lastIndex
386
+ }
387
+
388
+ // Add remaining text
389
+ if (lastIndex < value.length) {
390
+ fragments.push({
391
+ type: 'text',
392
+ content: value.substring(lastIndex),
393
+ })
394
+ }
395
+
396
+ return fragments
397
+ }
398
+
399
+ /**
400
+ * Creates a mention chip element
401
+ */
402
+ private createMentionChip(mention: MentionSuggestion): HTMLElement {
403
+ const wrapper = document.createElement('span')
404
+ wrapper.className = 'mention-chip'
405
+ wrapper.setAttribute('data-mention-id', mention.id)
406
+ wrapper.setAttribute('contenteditable', 'false')
407
+
408
+ const chip = document.createElement('ui-chip')
409
+ chip.setAttribute('type', 'input')
410
+ chip.setAttribute('removable', 'true')
411
+ chip.textContent = mention.label
412
+ chip.addEventListener('remove', () => {
413
+ this.removeMention(wrapper, mention)
414
+ })
415
+
416
+ wrapper.appendChild(chip)
417
+ return wrapper
418
+ }
419
+
420
+ /**
421
+ * Updates content state flags
422
+ */
423
+ private updateContentState(): void {
424
+ const hasAnyContent =
425
+ this.editorElement.childNodes.length > 0 && (this.editorElement.textContent?.trim().length || 0) > 0
426
+
427
+ this.hasContent = hasAnyContent
428
+ this.isLabelFloating = hasAnyContent || this.isShowingSuggestions || this.isEditorFocus
429
+ }
430
+
431
+ /**
432
+ * Handles input events in the editor
433
+ */
434
+ private handleEditorInput(event: Event): void {
435
+ event.stopPropagation()
436
+
437
+ // Check for mention trigger
438
+ this.checkForMentionTrigger()
439
+
440
+ // Sync value
441
+ this.syncValueFromEditor()
442
+ }
443
+
444
+ /**
445
+ * Handles keydown events in the editor
446
+ */
447
+ private handleEditorKeyDown(event: KeyboardEvent): void {
448
+ if (this.isShowingSuggestions) {
449
+ this.handleSuggestionKeyDown(event)
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Handles keydown events when suggestions are visible
455
+ */
456
+ private handleSuggestionKeyDown(event: KeyboardEvent): void {
457
+ switch (event.key) {
458
+ case 'ArrowUp':
459
+ event.preventDefault()
460
+ this.selectedSuggestionIndex = Math.max(0, this.selectedSuggestionIndex - 1)
461
+ break
462
+ case 'ArrowDown':
463
+ event.preventDefault()
464
+ this.selectedSuggestionIndex = Math.min(this.filteredSuggestions.length - 1, this.selectedSuggestionIndex + 1)
465
+ break
466
+ case 'Enter':
467
+ case 'Tab':
468
+ event.preventDefault()
469
+ this.selectSuggestion(this.filteredSuggestions[this.selectedSuggestionIndex])
470
+ break
471
+ case 'Escape':
472
+ event.preventDefault()
473
+ this.hideSuggestions()
474
+ break
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Handles focus events
480
+ */
481
+ private handleEditorFocus(): void {
482
+ this.isEditorFocus = true
483
+ this.updateContentState()
484
+ }
485
+
486
+ /**
487
+ * Handles blur events
488
+ */
489
+ private handleEditorBlur(event: FocusEvent): void {
490
+ // Don't hide suggestions if focus moved to suggestions popover
491
+ if (event.relatedTarget && this.suggestionsPopover?.contains(event.relatedTarget as Node)) {
492
+ return
493
+ }
494
+
495
+ this.isEditorFocus = false
496
+ this.hideSuggestions()
497
+ this.updateContentState()
498
+ this.dispatchEvent(new Event('change', { bubbles: true }))
499
+ }
500
+
501
+ private handleEditorPaste(event: ClipboardEvent): void {
502
+ // Prevent default paste behavior to handle mentions properly
503
+ event.preventDefault()
504
+
505
+ const pastedContent = event.clipboardData?.getData('text/plain')
506
+ if (!pastedContent) return
507
+
508
+ const selection = window.getSelection()
509
+ if (!selection || selection.rangeCount === 0) return
510
+
511
+ // Get the current range
512
+ let range: Range | StaticRange
513
+ if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function' && this.shadowRoot) {
514
+ const composedRanges = selection.getComposedRanges({ shadowRoots: [this.shadowRoot] })
515
+ if (composedRanges.length === 0) return
516
+ range = composedRanges[0]
517
+ } else {
518
+ range = selection.getRangeAt(0)
519
+ }
520
+
521
+ // Convert StaticRange to Range if needed for deleteContents
522
+ let workingRange: Range
523
+ if (range instanceof StaticRange) {
524
+ workingRange = document.createRange()
525
+ workingRange.setStart(range.startContainer, range.startOffset)
526
+ workingRange.setEnd(range.endContainer, range.endOffset)
527
+ } else {
528
+ workingRange = range
529
+ }
530
+
531
+ // Delete the current selection (if any)
532
+ workingRange.deleteContents()
533
+
534
+ // Insert the pasted text at the current position
535
+ const textNode = document.createTextNode(pastedContent)
536
+ workingRange.insertNode(textNode)
537
+
538
+ // Position cursor at the end of the inserted text
539
+ workingRange.setStartAfter(textNode)
540
+ workingRange.setEndAfter(textNode)
541
+ selection.removeAllRanges()
542
+ selection.addRange(workingRange)
543
+
544
+ // Hide suggestions if showing
545
+ this.hideSuggestions()
546
+
547
+ // Sync value and trigger events
548
+ this.syncValueFromEditor()
549
+ }
550
+
551
+ /**
552
+ * Checks if the current caret position indicates a mention trigger
553
+ */
554
+ private checkForMentionTrigger(): void {
555
+ const selection = window.getSelection()
556
+ if (!selection || selection.rangeCount === 0) {
557
+ this.hideSuggestions()
558
+ return
559
+ }
560
+
561
+ // Use getComposedRanges() for shadow DOM support, fallback to getRangeAt()
562
+ let range: Range | StaticRange
563
+ if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function' && this.shadowRoot) {
564
+ const composedRanges = selection.getComposedRanges({ shadowRoots: [this.shadowRoot] })
565
+ if (composedRanges.length === 0) {
566
+ this.hideSuggestions()
567
+ return
568
+ }
569
+ range = composedRanges[0]
570
+ } else {
571
+ range = selection.getRangeAt(0)
572
+ }
573
+
574
+ const caretContainer = range.startContainer
575
+ const caretOffset = range.startOffset
576
+
577
+ // Ensure the caret is within our editor element
578
+ if (!this.editorElement.contains(caretContainer)) {
579
+ this.hideSuggestions()
580
+ return
581
+ }
582
+
583
+ // Only look for triggers in text nodes
584
+ if (caretContainer.nodeType !== Node.TEXT_NODE) {
585
+ this.hideSuggestions()
586
+ return
587
+ }
588
+
589
+ const textContent = caretContainer.textContent || ''
590
+
591
+ // Look backwards from caret to find mention trigger
592
+ let mentionStart = -1
593
+ for (let i = caretOffset - 1; i >= 0; i--) {
594
+ const char = textContent[i]
595
+ if (char === this.mentionTrigger) {
596
+ mentionStart = i
597
+ break
598
+ }
599
+ if (char === ' ' || char === '\n') {
600
+ break
601
+ }
602
+ }
603
+ if (mentionStart >= 0) {
604
+ const query = textContent.substring(mentionStart + 1, caretOffset)
605
+
606
+ if (query.length >= this.minQueryLength) {
607
+ this.currentMentionQuery = query
608
+ this.currentMentionStart = this.getGlobalTextPosition(caretContainer, mentionStart)
609
+ this.showSuggestions()
610
+ this.updateFilteredSuggestions()
611
+ } else {
612
+ this.hideSuggestions()
613
+ }
614
+ } else {
615
+ this.hideSuggestions()
616
+ }
617
+ }
618
+
619
+ private walkerNodeFilter(node: Node): number {
620
+ if (node.nodeType === Node.TEXT_NODE) {
621
+ return NodeFilter.FILTER_ACCEPT
622
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
623
+ const element = node as Element
624
+ if (element.classList.contains('mention-chip')) {
625
+ // Accept mention chips as single characters
626
+ return NodeFilter.FILTER_ACCEPT
627
+ } else if (element.parentElement?.classList.contains('mention-chip')) {
628
+ // It's a chip, ignore the entire tree
629
+ return NodeFilter.FILTER_REJECT
630
+ }
631
+ }
632
+ return NodeFilter.FILTER_SKIP
633
+ }
634
+
635
+ /**
636
+ * Gets the global text position within the editor for a position within a text node
637
+ */
638
+ private getGlobalTextPosition(textNode: Node, offsetInNode: number): number {
639
+ let position = 0
640
+ const walker = document.createTreeWalker(this.editorElement, ShowMask, this.walkerNodeFilter)
641
+
642
+ let node = walker.nextNode()
643
+ while (node && node !== textNode) {
644
+ if (node.nodeType === Node.TEXT_NODE) {
645
+ position += node.textContent?.length || 0
646
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
647
+ const element = node as Element
648
+ if (element.classList?.contains('mention-chip')) {
649
+ position += 1 // Count chip as 1 character
650
+ }
651
+ }
652
+ node = walker.nextNode()
653
+ }
654
+
655
+ return position + offsetInNode
656
+ }
657
+
658
+ /**
659
+ * Finds the DOM node and offset that corresponds to a global text position
660
+ */
661
+ private findNodeAtGlobalPosition(globalPosition: number): { node: Node; offset: number } | null {
662
+ let currentPosition = 0
663
+ const walker = document.createTreeWalker(this.editorElement, ShowMask, this.walkerNodeFilter)
664
+
665
+ let node = walker.nextNode()
666
+ while (node) {
667
+ if (node.nodeType === Node.TEXT_NODE) {
668
+ const textLength = node.textContent?.length || 0
669
+ if (currentPosition + textLength >= globalPosition) {
670
+ return {
671
+ node,
672
+ offset: globalPosition - currentPosition,
673
+ }
674
+ }
675
+ currentPosition += textLength
676
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
677
+ const element = node as Element
678
+ if (element.classList?.contains('mention-chip')) {
679
+ if (currentPosition === globalPosition) {
680
+ // Position is right before the chip
681
+ const parentNode = element.parentNode
682
+ if (parentNode) {
683
+ return {
684
+ node: parentNode,
685
+ offset: Array.from(parentNode.childNodes).indexOf(element),
686
+ }
687
+ }
688
+ }
689
+ currentPosition += 1 // Count chip as 1 character
690
+ }
691
+ }
692
+ node = walker.nextNode()
693
+ }
694
+
695
+ // Position is beyond the end, return the last position
696
+ const lastChild = this.editorElement.lastChild
697
+ if (lastChild) {
698
+ if (lastChild.nodeType === Node.TEXT_NODE) {
699
+ return {
700
+ node: lastChild,
701
+ offset: lastChild.textContent?.length || 0,
702
+ }
703
+ } else {
704
+ return {
705
+ node: this.editorElement,
706
+ offset: this.editorElement.childNodes.length,
707
+ }
708
+ }
709
+ }
710
+
711
+ return null
712
+ }
713
+
714
+ /**
715
+ * Shows the suggestions popover
716
+ */
717
+ private showSuggestions(): void {
718
+ if (!this.isShowingSuggestions) {
719
+ this.isShowingSuggestions = true
720
+ this.selectedSuggestionIndex = 0
721
+ this.updateContentState()
722
+
723
+ // Use Popover API to show the popover
724
+ this.updateComplete.then(() => {
725
+ if (this.suggestionsPopover) {
726
+ this.suggestionsPopover.showPopover()
727
+ this.positionSuggestions()
728
+ }
729
+ })
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Hides the suggestions popover
735
+ */
736
+ private hideSuggestions(): void {
737
+ if (this.isShowingSuggestions) {
738
+ this.isShowingSuggestions = false
739
+ this.currentMentionQuery = ''
740
+ this.currentMentionStart = -1
741
+ this.updateContentState()
742
+
743
+ // Use Popover API to hide the popover
744
+ if (this.suggestionsPopover) {
745
+ this.suggestionsPopover.hidePopover()
746
+ }
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Updates filtered suggestions based on current query
752
+ */
753
+ private updateFilteredSuggestions(): void {
754
+ if (!this.currentMentionQuery) {
755
+ this.filteredSuggestions = this.suggestions.slice(0, 10) // Limit to 10
756
+ return
757
+ }
758
+
759
+ const query = this.currentMentionQuery.toLowerCase()
760
+ this.filteredSuggestions = this.suggestions
761
+ .filter(
762
+ (suggestion) =>
763
+ suggestion.label.toLowerCase().includes(query) ||
764
+ suggestion.description?.toLowerCase().includes(query) ||
765
+ suggestion.suffix?.toLowerCase().includes(query)
766
+ )
767
+ .slice(0, 10) // Limit to 10
768
+
769
+ // Reset selection if needed
770
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
771
+ this.selectedSuggestionIndex = 0
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Positions the suggestions popover using the Popover API anchor positioning
777
+ */
778
+ private positionSuggestions(): void {
779
+ if (!this.suggestionsPopover) return
780
+
781
+ const selection = window.getSelection()
782
+ if (!selection || selection.rangeCount === 0) return
783
+
784
+ // Use getComposedRanges() for shadow DOM support, fallback to getRangeAt()
785
+ let range: Range | StaticRange
786
+ if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function' && this.shadowRoot) {
787
+ const composedRanges = selection.getComposedRanges({ shadowRoots: [this.shadowRoot] })
788
+ if (composedRanges.length === 0) return
789
+ range = composedRanges[0]
790
+ } else {
791
+ range = selection.getRangeAt(0)
792
+ }
793
+
794
+ // Convert StaticRange to Range if needed for getBoundingClientRect
795
+ let workingRange: Range
796
+ if (range instanceof StaticRange) {
797
+ workingRange = document.createRange()
798
+ workingRange.setStart(range.startContainer, range.startOffset)
799
+ workingRange.setEnd(range.endContainer, range.endOffset)
800
+ } else {
801
+ workingRange = range
802
+ }
803
+
804
+ const rect = workingRange.getBoundingClientRect()
805
+
806
+ // Position suggestions below the caret using manual positioning as fallback
807
+ // The Popover API will handle proper layering and overflow handling
808
+ this.suggestionsPopover.style.left = `${rect.left}px`
809
+ this.suggestionsPopover.style.top = `${rect.bottom + 4}px`
810
+ }
811
+
812
+ /**
813
+ * Selects a suggestion and inserts it as a mention
814
+ */
815
+ private selectSuggestion(suggestion: MentionSuggestion): void {
816
+ if (!suggestion) return
817
+
818
+ // Add to mention map
819
+ this.mentionMap.set(suggestion.id, suggestion)
820
+
821
+ // Find the position where the mention trigger starts
822
+ const mentionStart = this.currentMentionStart
823
+
824
+ // Find the DOM node and offset for the mention start position
825
+ const mentionStartInfo = this.findNodeAtGlobalPosition(mentionStart)
826
+
827
+ if (!mentionStartInfo) {
828
+ // Fallback: insert at the end
829
+ const chip = this.createMentionChip(suggestion)
830
+ const afterTextNode = document.createTextNode('')
831
+ this.editorElement.appendChild(chip)
832
+ this.editorElement.appendChild(afterTextNode)
833
+ this.setCaretPositionAtTextNode(afterTextNode, 0)
834
+ this.hideSuggestions()
835
+ this.syncValueFromEditor()
836
+ this.dispatchEvent(
837
+ new CustomEvent<MentionInsertEvent>('mention-insert', {
838
+ detail: {
839
+ suggestion,
840
+ trigger: this.mentionTrigger + this.currentMentionQuery,
841
+ position: mentionStart,
842
+ },
843
+ bubbles: true,
844
+ })
845
+ )
846
+ return
847
+ }
848
+
849
+ // If the target is not a text node, insert at the container level
850
+ if (mentionStartInfo.node.nodeType !== Node.TEXT_NODE) {
851
+ const chip = this.createMentionChip(suggestion)
852
+ const container = mentionStartInfo.node
853
+ const beforeNode = container.childNodes[mentionStartInfo.offset]
854
+
855
+ // Create an empty text node for caret positioning
856
+ const afterTextNode = document.createTextNode('')
857
+
858
+ if (beforeNode) {
859
+ container.insertBefore(chip, beforeNode)
860
+ container.insertBefore(afterTextNode, beforeNode)
861
+ } else {
862
+ container.appendChild(chip)
863
+ container.appendChild(afterTextNode)
864
+ }
865
+
866
+ // Position caret at the beginning of the after text node
867
+ this.setCaretPositionAtTextNode(afterTextNode, 0)
868
+ this.hideSuggestions()
869
+ this.syncValueFromEditor()
870
+ this.dispatchEvent(
871
+ new CustomEvent<MentionInsertEvent>('mention-insert', {
872
+ detail: {
873
+ suggestion,
874
+ trigger: this.mentionTrigger + this.currentMentionQuery,
875
+ position: mentionStart,
876
+ },
877
+ bubbles: true,
878
+ })
879
+ )
880
+ return
881
+ }
882
+
883
+ // We have a text node - split it and insert the chip
884
+ const textNode = mentionStartInfo.node as Text
885
+ const splitOffset = mentionStartInfo.offset
886
+
887
+ // Create the mention chip
888
+ const chip = this.createMentionChip(suggestion)
889
+
890
+ // Split the text node at the mention start position
891
+ const beforeText = textNode.textContent?.substring(0, splitOffset) || ''
892
+ const afterText = textNode.textContent?.substring(splitOffset + 1) || '' // +1 to skip the trigger
893
+
894
+ // Replace the original text node with the split content
895
+ const parentNode = textNode.parentNode
896
+ if (parentNode) {
897
+ // Create new text nodes - always create afterTextNode even if empty
898
+ const beforeTextNode = beforeText ? document.createTextNode(beforeText) : null
899
+ const afterTextNode = document.createTextNode(afterText) // Always create, even if empty
900
+
901
+ // Insert the nodes in order
902
+ if (beforeTextNode) {
903
+ parentNode.insertBefore(beforeTextNode, textNode)
904
+ }
905
+ parentNode.insertBefore(chip, textNode)
906
+ parentNode.insertBefore(afterTextNode, textNode)
907
+
908
+ // Remove the original text node
909
+ parentNode.removeChild(textNode)
910
+
911
+ // Position caret at the beginning of the after text node
912
+ this.setCaretPositionAtTextNode(afterTextNode, 0)
913
+ } else {
914
+ // Fallback: append to editor and create a text node for caret positioning
915
+ const afterTextNode = document.createTextNode('')
916
+ this.editorElement.appendChild(chip)
917
+ this.editorElement.appendChild(afterTextNode)
918
+ this.setCaretPositionAtTextNode(afterTextNode, 0)
919
+ }
920
+
921
+ // Hide suggestions
922
+ this.hideSuggestions()
923
+
924
+ // Sync value and dispatch events
925
+ this.syncValueFromEditor()
926
+ this.dispatchEvent(
927
+ new CustomEvent<MentionInsertEvent>('mention-insert', {
928
+ detail: {
929
+ suggestion,
930
+ trigger: this.mentionTrigger + this.currentMentionQuery,
931
+ position: mentionStart,
932
+ },
933
+ bubbles: true,
934
+ })
935
+ )
936
+ }
937
+
938
+ /**
939
+ * Removes a mention chip
940
+ */
941
+ private removeMention(chipElement: HTMLElement, mention: MentionSuggestion): void {
942
+ const position = this.getElementPosition(chipElement)
943
+ chipElement.remove()
944
+
945
+ this.syncValueFromEditor()
946
+ this.dispatchEvent(
947
+ new CustomEvent<MentionRemoveEvent>('mention-remove', {
948
+ detail: {
949
+ suggestion: mention,
950
+ position,
951
+ },
952
+ bubbles: true,
953
+ })
954
+ )
955
+ }
956
+
957
+ /**
958
+ * Gets the text position of an element
959
+ */
960
+ private getElementPosition(element: HTMLElement): number {
961
+ let position = 0
962
+ let currentNode = this.editorElement.firstChild
963
+
964
+ while (currentNode && currentNode !== element) {
965
+ if (currentNode.nodeType === Node.TEXT_NODE) {
966
+ position += currentNode.textContent?.length || 0
967
+ } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
968
+ const element = currentNode as Element
969
+ if (element.classList?.contains('mention-chip')) {
970
+ position += 1 // Count chip as 1 character
971
+ }
972
+ }
973
+ currentNode = currentNode.nextSibling
974
+ }
975
+
976
+ return position
977
+ }
978
+
979
+ /**
980
+ * Handles clicking on a suggestion
981
+ */
982
+ private handleSuggestionClick(suggestion: MentionSuggestion, event: Event): void {
983
+ event.preventDefault()
984
+ this.selectSuggestion(suggestion)
985
+ this.focus()
986
+ }
987
+
988
+ /**
989
+ * Renders a suggestion item
990
+ */
991
+ private renderSuggestion(suggestion: MentionSuggestion, index: number): TemplateResult {
992
+ const isSelected = index === this.selectedSuggestionIndex
993
+ const classes = classMap({
994
+ 'suggestion-item': true,
995
+ 'selected': isSelected,
996
+ })
997
+
998
+ return html`
999
+ <button
1000
+ class="${classes}"
1001
+ @click="${(e: Event) => this.handleSuggestionClick(suggestion, e)}"
1002
+ @mouseenter="${() => (this.selectedSuggestionIndex = index)}"
1003
+ >
1004
+ <div class="suggestion-content">
1005
+ <div class="suggestion-headline">${suggestion.label}</div>
1006
+ ${suggestion.description
1007
+ ? html` <div class="suggestion-supporting-text">${suggestion.description}</div> `
1008
+ : nothing}
1009
+ </div>
1010
+ ${suggestion.suffix ? html` <div class="suggestion-suffix">${suggestion.suffix}</div> ` : nothing}
1011
+ </button>
1012
+ `
1013
+ }
1014
+
1015
+ /**
1016
+ * Renders the suggestions popover
1017
+ */
1018
+ private renderSuggestions(): TemplateResult {
1019
+ return html`
1020
+ <div class="suggestions-popover" popover="manual">
1021
+ ${this.isShowingSuggestions && this.filteredSuggestions.length > 0
1022
+ ? this.filteredSuggestions.map((suggestion, index) => this.renderSuggestion(suggestion, index))
1023
+ : nothing}
1024
+ </div>
1025
+ `
1026
+ }
1027
+
1028
+ override render(): TemplateResult {
1029
+ const surfaceClasses = classMap({
1030
+ 'surface': true,
1031
+ 'has-content': this.hasContent,
1032
+ })
1033
+
1034
+ const labelClasses = classMap({
1035
+ label: true,
1036
+ floating: this.isLabelFloating,
1037
+ })
1038
+
1039
+ return html`
1040
+ <div class="${surfaceClasses}">
1041
+ <div class="container"></div>
1042
+ <div class="content">
1043
+ <div class="body">
1044
+ ${this.label ? html`<div class="${labelClasses}">${this.label}</div>` : nothing}
1045
+ <div
1046
+ class="editor"
1047
+ contenteditable="${!this.disabled}"
1048
+ data-placeholder="${ifDefined(this.placeholder)}"
1049
+ @input="${this.handleEditorInput}"
1050
+ @keydown="${this.handleEditorKeyDown}"
1051
+ @focus="${this.handleEditorFocus}"
1052
+ @blur="${this.handleEditorBlur}"
1053
+ @paste="${this.handleEditorPaste}"
1054
+ role="textbox"
1055
+ aria-label="${ifDefined(this.label)}"
1056
+ aria-multiline="true"
1057
+ aria-required="${this.required}"
1058
+ aria-invalid="${this.invalid}"
1059
+ tabindex="${this.disabled ? -1 : 0}"
1060
+ ></div>
1061
+ </div>
1062
+ </div>
1063
+ ${this.renderSuggestions()}
1064
+ </div>
1065
+ ${this.supportingText ? html` <div class="supporting-text">${this.supportingText}</div> ` : nothing}
1066
+ `
1067
+ }
1068
+ }