@api-client/ui 0.5.6 → 0.5.8

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