@bbki.ng/bb-msg-history 0.14.1 → 2.0.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 (54) hide show
  1. package/dist/component.d.ts +6 -18
  2. package/dist/component.js +43 -275
  3. package/dist/components/bb-custom-avatar.d.ts +20 -0
  4. package/dist/components/bb-custom-avatar.js +145 -0
  5. package/dist/components/bb-letter-avatar.d.ts +14 -0
  6. package/dist/components/bb-letter-avatar.js +61 -0
  7. package/dist/components/bb-loading-overlay.d.ts +14 -0
  8. package/dist/components/bb-loading-overlay.js +89 -0
  9. package/dist/components/bb-message-bubble.d.ts +19 -0
  10. package/dist/components/bb-message-bubble.js +116 -0
  11. package/dist/components/bb-message.d.ts +27 -0
  12. package/dist/components/bb-message.js +174 -0
  13. package/dist/components/bb-msg-history.d.ts +111 -0
  14. package/dist/components/bb-msg-history.js +473 -0
  15. package/dist/components/bb-scroll-button.d.ts +16 -0
  16. package/dist/components/bb-scroll-button.js +161 -0
  17. package/dist/components/bb-timestamp.d.ts +15 -0
  18. package/dist/components/bb-timestamp.js +59 -0
  19. package/dist/components/index.d.ts +7 -0
  20. package/dist/components/index.js +7 -0
  21. package/dist/const/authors.js +1 -1
  22. package/dist/const/styles.js +0 -33
  23. package/dist/contexts/author-context.d.ts +8 -0
  24. package/dist/contexts/author-context.js +6 -0
  25. package/dist/controllers/scroll-controller.d.ts +52 -0
  26. package/dist/controllers/scroll-controller.js +138 -0
  27. package/dist/core/message-processor.d.ts +56 -0
  28. package/dist/core/message-processor.js +85 -0
  29. package/dist/core/renderer.d.ts +87 -0
  30. package/dist/core/renderer.js +196 -0
  31. package/dist/core/scroll-manager.d.ts +54 -0
  32. package/dist/core/scroll-manager.js +119 -0
  33. package/dist/parsers/base.d.ts +21 -0
  34. package/dist/parsers/base.js +1 -0
  35. package/dist/parsers/default-parser.d.ts +10 -0
  36. package/dist/parsers/default-parser.js +40 -0
  37. package/dist/parsers/index.d.ts +2 -0
  38. package/dist/parsers/index.js +1 -0
  39. package/dist/utils/event-tracker.d.ts +23 -0
  40. package/dist/utils/event-tracker.js +33 -0
  41. package/dist/utils/message-builder.d.ts +0 -4
  42. package/dist/utils/message-builder.js +0 -15
  43. package/dist/utils/tooltip.d.ts +11 -2
  44. package/dist/utils/tooltip.js +56 -13
  45. package/package.json +1 -1
  46. package/src/component.ts +56 -338
  47. package/src/const/authors.ts +3 -2
  48. package/src/const/styles.ts +0 -33
  49. package/src/core/message-processor.ts +120 -0
  50. package/src/core/renderer.ts +276 -0
  51. package/src/core/scroll-manager.ts +148 -0
  52. package/src/utils/event-tracker.ts +38 -0
  53. package/src/utils/message-builder.ts +0 -15
  54. package/src/utils/tooltip.ts +0 -16
@@ -0,0 +1,196 @@
1
+ import { EMPTY_STYLES, LOADING_STYLES, MAIN_STYLES } from '../const/styles.js';
2
+ import { resolveAuthorConfig } from '../utils/author-resolver.js';
3
+ import { buildMessageRowHtml } from '../utils/message-builder.js';
4
+ import { buildScrollButtonHtml } from '../utils/scroll-button.js';
5
+ /**
6
+ * Renderer - Manages all DOM rendering operations
7
+ *
8
+ * Centralizes DOM manipulation logic:
9
+ * - Full render of all messages
10
+ * - Incremental update when appending single messages
11
+ * - Empty state rendering
12
+ * - Loading overlay management
13
+ */
14
+ export class Renderer {
15
+ constructor(shadowRoot) {
16
+ this.shadowRoot = shadowRoot;
17
+ }
18
+ /**
19
+ * Render the complete message history
20
+ *
21
+ * @param messages - Processed messages with grouping metadata
22
+ * @param authors - User-defined author configurations
23
+ * @param isLoading - Whether to show loading overlay
24
+ * @param hideScrollButton - Whether to hide the scroll-to-bottom button
25
+ * @returns Result indicating if we were at bottom before render
26
+ */
27
+ render(messages, authors, isLoading, hideScrollButton) {
28
+ // Check if we need to create or update the structure
29
+ const historyContainer = this.shadowRoot.querySelector('.history');
30
+ const needsFullSetup = !historyContainer;
31
+ // Build messages HTML
32
+ const messagesHtml = this.buildMessagesHtml(messages, authors);
33
+ if (needsFullSetup) {
34
+ // First render - create full structure
35
+ return this.renderFullStructure(messagesHtml, isLoading, hideScrollButton);
36
+ }
37
+ else {
38
+ // Update only - preserve DOM structure
39
+ return this.updateContent(historyContainer, messagesHtml, isLoading);
40
+ }
41
+ }
42
+ /**
43
+ * Build HTML string for all messages
44
+ */
45
+ buildMessagesHtml(messages, authors) {
46
+ return messages
47
+ .map(msg => {
48
+ const config = resolveAuthorConfig(msg.author, authors);
49
+ return buildMessageRowHtml(msg.author, msg.text, config, !msg.isFirstFromAuthor, // isSubsequent
50
+ msg.groupTimestamp, msg.isLastInGroup);
51
+ })
52
+ .join('');
53
+ }
54
+ /**
55
+ * Render full structure including styles, container, and scroll button
56
+ */
57
+ renderFullStructure(messagesHtml, isLoading, hideScrollButton) {
58
+ // For initial render, we consider it as "was at bottom" to scroll down
59
+ const wasAtBottom = true;
60
+ const loadingOverlay = isLoading
61
+ ? `<div class="loading-overlay" role="status" aria-label="Loading messages">
62
+ <div class="loading-spinner"></div>
63
+ </div>`
64
+ : '';
65
+ this.shadowRoot.innerHTML = `
66
+ <style>${MAIN_STYLES}${LOADING_STYLES}</style>
67
+ <div class="history" role="log" aria-live="polite" aria-label="Message history">
68
+ ${messagesHtml}
69
+ </div>
70
+ ${hideScrollButton ? '' : buildScrollButtonHtml()}
71
+ ${loadingOverlay}
72
+ `;
73
+ return { wasAtBottom };
74
+ }
75
+ /**
76
+ * Update content while preserving DOM structure
77
+ */
78
+ updateContent(historyContainer, messagesHtml, isLoading) {
79
+ // Check scroll position before update
80
+ const scrollContainer = historyContainer;
81
+ const wasAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
82
+ // Update messages content only
83
+ historyContainer.innerHTML = messagesHtml;
84
+ // Update loading overlay
85
+ this.updateLoadingOverlay(isLoading);
86
+ // Restore scroll position or scroll to bottom if we were there
87
+ if (wasAtBottom) {
88
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
89
+ }
90
+ return { wasAtBottom };
91
+ }
92
+ /**
93
+ * Append a single message without full re-render
94
+ *
95
+ * @param message - The message to append
96
+ * @param authors - User-defined author configurations
97
+ * @param lastState - Previous state for grouping logic
98
+ * @returns Result indicating success and updated state
99
+ */
100
+ appendSingleMessage(message, authors, lastState) {
101
+ const container = this.shadowRoot.querySelector('.history');
102
+ // If empty state or no container, signal that full render is needed
103
+ if (!container) {
104
+ return { success: false, lastAuthor: lastState.author };
105
+ }
106
+ const config = resolveAuthorConfig(message.author, authors);
107
+ // Determine grouping
108
+ const prevMessage = lastState.author
109
+ ? { author: lastState.author, text: '', timestamp: lastState.groupTimestamp }
110
+ : null;
111
+ const canGroupWithLast = this.canGroupMessages(prevMessage, message);
112
+ const isFirstFromAuthor = !canGroupWithLast;
113
+ // Calculate new state
114
+ let lastGroupTimestamp = lastState.groupTimestamp;
115
+ if (isFirstFromAuthor) {
116
+ lastGroupTimestamp = message.timestamp;
117
+ }
118
+ else if (!lastGroupTimestamp && message.timestamp) {
119
+ lastGroupTimestamp = message.timestamp;
120
+ }
121
+ // Build and append HTML
122
+ const msgHtml = buildMessageRowHtml(message.author, message.text, config, !isFirstFromAuthor, // isSubsequent
123
+ lastGroupTimestamp, true // isLastInGroup - when appending, this is always last (for now)
124
+ );
125
+ container.insertAdjacentHTML('beforeend', msgHtml);
126
+ return {
127
+ success: true,
128
+ lastAuthor: message.author,
129
+ lastGroupTimestamp,
130
+ };
131
+ }
132
+ /**
133
+ * Check if two messages can be grouped
134
+ */
135
+ canGroupMessages(prev, curr) {
136
+ if (!prev)
137
+ return false;
138
+ if (prev.author !== curr.author)
139
+ return false;
140
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
141
+ return false;
142
+ }
143
+ return true;
144
+ }
145
+ /**
146
+ * Render empty state (no messages)
147
+ */
148
+ renderEmpty(isLoading) {
149
+ if (isLoading) {
150
+ // Show loading overlay with minimum height
151
+ this.shadowRoot.innerHTML = `
152
+ <style>${EMPTY_STYLES}${LOADING_STYLES}</style>
153
+ <div style="position: relative; min-height: 120px;">
154
+ <div class="loading-overlay" role="status" aria-label="Loading messages">
155
+ <div class="loading-spinner"></div>
156
+ </div>
157
+ </div>
158
+ `;
159
+ }
160
+ else {
161
+ this.shadowRoot.innerHTML = `
162
+ <style>${EMPTY_STYLES}</style>
163
+ <div class="empty-state">No messages</div>
164
+ `;
165
+ }
166
+ }
167
+ /**
168
+ * Update loading overlay visibility
169
+ */
170
+ updateLoadingOverlay(shouldShow) {
171
+ const existingOverlay = this.shadowRoot.querySelector('.loading-overlay');
172
+ if (shouldShow && !existingOverlay) {
173
+ const overlay = document.createElement('div');
174
+ overlay.className = 'loading-overlay';
175
+ overlay.setAttribute('role', 'status');
176
+ overlay.setAttribute('aria-label', 'Loading messages');
177
+ overlay.innerHTML = '<div class="loading-spinner"></div>';
178
+ this.shadowRoot.appendChild(overlay);
179
+ }
180
+ else if (!shouldShow && existingOverlay) {
181
+ existingOverlay.remove();
182
+ }
183
+ }
184
+ /**
185
+ * Get the history container element
186
+ */
187
+ getHistoryContainer() {
188
+ return this.shadowRoot.querySelector('.history');
189
+ }
190
+ /**
191
+ * Get the scroll-to-bottom button element
192
+ */
193
+ getScrollButton() {
194
+ return this.shadowRoot.querySelector('.scroll-to-bottom');
195
+ }
196
+ }
@@ -0,0 +1,54 @@
1
+ import type { EventTracker } from '../utils/event-tracker.js';
2
+ /**
3
+ * ScrollManager - Handles scroll behavior, position detection, and scroll button visibility
4
+ *
5
+ * Isolates all scroll-related logic from the main component:
6
+ * - Scroll to bottom with smooth animation
7
+ * - Detect when user is near/at bottom of content
8
+ * - Control scroll button visibility based on scroll position
9
+ * - Dispatch custom events for scroll button state changes
10
+ */
11
+ export declare class ScrollManager {
12
+ private host;
13
+ private shadowRoot;
14
+ private eventTracker;
15
+ private onVisibilityChange;
16
+ private container?;
17
+ private button?;
18
+ private isButtonVisible;
19
+ private readonly BOTTOM_THRESHOLD;
20
+ constructor(host: HTMLElement, shadowRoot: ShadowRoot, eventTracker: EventTracker, onVisibilityChange: (visible: boolean) => void);
21
+ /**
22
+ * Initialize scroll tracking on the container
23
+ *
24
+ * @param container - The scrollable history container
25
+ * @param button - Optional scroll-to-bottom button element
26
+ * @param skipInitialCheck - If true, skip the initial position check (for initial render)
27
+ */
28
+ init(container: HTMLElement, button?: HTMLButtonElement | null, skipInitialCheck?: boolean): void;
29
+ /**
30
+ * Check current scroll position and update button visibility
31
+ * Dispatches custom events when visibility changes
32
+ */
33
+ checkPosition(): void;
34
+ /**
35
+ * Scroll the container to the bottom
36
+ *
37
+ * @param behavior - Scroll behavior: 'smooth' or 'auto'
38
+ */
39
+ scrollToBottom(behavior?: ScrollBehavior): void;
40
+ /**
41
+ * Check if the container is currently at or near the bottom
42
+ *
43
+ * @returns true if within threshold pixels of bottom
44
+ */
45
+ isAtBottom(): boolean;
46
+ /**
47
+ * Get the current button visibility state
48
+ */
49
+ get isVisible(): boolean;
50
+ /**
51
+ * Get the scrollable container element
52
+ */
53
+ getContainer(): HTMLElement | undefined;
54
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ScrollManager - Handles scroll behavior, position detection, and scroll button visibility
3
+ *
4
+ * Isolates all scroll-related logic from the main component:
5
+ * - Scroll to bottom with smooth animation
6
+ * - Detect when user is near/at bottom of content
7
+ * - Control scroll button visibility based on scroll position
8
+ * - Dispatch custom events for scroll button state changes
9
+ */
10
+ export class ScrollManager {
11
+ constructor(host, shadowRoot, eventTracker, onVisibilityChange) {
12
+ this.host = host;
13
+ this.shadowRoot = shadowRoot;
14
+ this.eventTracker = eventTracker;
15
+ this.onVisibilityChange = onVisibilityChange;
16
+ this.isButtonVisible = false;
17
+ // Threshold in pixels from bottom to consider "at bottom"
18
+ this.BOTTOM_THRESHOLD = 50;
19
+ }
20
+ /**
21
+ * Initialize scroll tracking on the container
22
+ *
23
+ * @param container - The scrollable history container
24
+ * @param button - Optional scroll-to-bottom button element
25
+ * @param skipInitialCheck - If true, skip the initial position check (for initial render)
26
+ */
27
+ init(container, button, skipInitialCheck = false) {
28
+ this.container = container;
29
+ this.button = button ?? null;
30
+ if (!skipInitialCheck) {
31
+ this.checkPosition();
32
+ }
33
+ // Listen for scroll events with passive listener for performance
34
+ this.eventTracker.add(container, 'scroll', () => this.checkPosition(), { passive: true });
35
+ // Also check on resize
36
+ this.eventTracker.add(window, 'resize', () => this.checkPosition());
37
+ // Setup button click handler
38
+ if (button && !this.host.hasAttribute('infinite')) {
39
+ button.addEventListener('click', () => this.scrollToBottom());
40
+ }
41
+ }
42
+ /**
43
+ * Check current scroll position and update button visibility
44
+ * Dispatches custom events when visibility changes
45
+ */
46
+ checkPosition() {
47
+ if (!this.container)
48
+ return;
49
+ const isAtBottom = this.isAtBottom();
50
+ const hasOverflow = this.container.scrollHeight > this.container.clientHeight;
51
+ // Show button when not at bottom and content has overflow
52
+ const shouldShow = !isAtBottom && hasOverflow;
53
+ if (shouldShow !== this.isButtonVisible) {
54
+ this.isButtonVisible = shouldShow;
55
+ // Update button UI
56
+ if (this.button) {
57
+ this.button.classList.toggle('visible', shouldShow);
58
+ }
59
+ // Notify parent component
60
+ this.onVisibilityChange(shouldShow);
61
+ // Dispatch custom event
62
+ this.host.dispatchEvent(new CustomEvent(shouldShow ? 'bb-scrollbuttonshow' : 'bb-scrollbuttonhide', {
63
+ bubbles: true,
64
+ composed: true,
65
+ detail: { visible: shouldShow },
66
+ }));
67
+ }
68
+ }
69
+ /**
70
+ * Scroll the container to the bottom
71
+ *
72
+ * @param behavior - Scroll behavior: 'smooth' or 'auto'
73
+ */
74
+ scrollToBottom(behavior = 'smooth') {
75
+ if (!this.container || this.host.hasAttribute('infinite')) {
76
+ return;
77
+ }
78
+ this.container.scrollTo({
79
+ top: this.container.scrollHeight,
80
+ behavior,
81
+ });
82
+ // Hide button since we're scrolling to bottom
83
+ if (this.isButtonVisible) {
84
+ this.isButtonVisible = false;
85
+ if (this.button) {
86
+ this.button.classList.remove('visible');
87
+ }
88
+ this.onVisibilityChange(false);
89
+ this.host.dispatchEvent(new CustomEvent('bb-scrollbuttonhide', {
90
+ bubbles: true,
91
+ composed: true,
92
+ detail: { visible: false },
93
+ }));
94
+ }
95
+ }
96
+ /**
97
+ * Check if the container is currently at or near the bottom
98
+ *
99
+ * @returns true if within threshold pixels of bottom
100
+ */
101
+ isAtBottom() {
102
+ if (!this.container)
103
+ return true;
104
+ const distanceFromBottom = this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight;
105
+ return distanceFromBottom < this.BOTTOM_THRESHOLD;
106
+ }
107
+ /**
108
+ * Get the current button visibility state
109
+ */
110
+ get isVisible() {
111
+ return this.isButtonVisible;
112
+ }
113
+ /**
114
+ * Get the scrollable container element
115
+ */
116
+ getContainer() {
117
+ return this.container;
118
+ }
119
+ }
@@ -0,0 +1,21 @@
1
+ import type { Message } from '../types/index.js';
2
+ /**
3
+ * Parser interface for message parsing
4
+ * Allows custom parsers to be plugged in
5
+ */
6
+ export interface MessageParser {
7
+ /**
8
+ * Parse text content into message array
9
+ * @param textContent - Raw text content to parse
10
+ * @returns Array of parsed messages
11
+ */
12
+ parse(textContent: string | null): Message[];
13
+ }
14
+ /**
15
+ * Input type for appending messages
16
+ */
17
+ export interface MessageInput {
18
+ author: string;
19
+ text: string;
20
+ timestamp?: string;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { Message } from '../types/index.js';
2
+ import type { MessageParser } from './base.js';
3
+ /**
4
+ * Default message parser implementation
5
+ * Format: `[timestamp] author: text` or `author: text` (one message per line)
6
+ * Timestamp is optional for backward compatibility
7
+ */
8
+ export declare class DefaultMessageParser implements MessageParser {
9
+ parse(textContent: string | null): Message[];
10
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Default message parser implementation
3
+ * Format: `[timestamp] author: text` or `author: text` (one message per line)
4
+ * Timestamp is optional for backward compatibility
5
+ */
6
+ export class DefaultMessageParser {
7
+ parse(textContent) {
8
+ const raw = textContent || '';
9
+ const messages = [];
10
+ // Pattern: [timestamp] author: text (space between timestamp and author is optional)
11
+ const timestampPattern = /^\[([^\]]+)\]\s*(.+)$/;
12
+ for (const line of raw.split('\n')) {
13
+ const trimmed = line.trim();
14
+ if (!trimmed)
15
+ continue;
16
+ let remainingLine = trimmed;
17
+ let timestamp;
18
+ // Try to extract timestamp
19
+ const timestampMatch = trimmed.match(timestampPattern);
20
+ if (timestampMatch) {
21
+ timestamp = timestampMatch[1].trim();
22
+ remainingLine = timestampMatch[2];
23
+ }
24
+ // Find the author:text separator (colon followed by space)
25
+ const colonIdx = remainingLine.indexOf(':');
26
+ if (colonIdx <= 0)
27
+ continue;
28
+ const author = remainingLine.slice(0, colonIdx).trim();
29
+ const text = remainingLine.slice(colonIdx + 1).trim();
30
+ if (author && text) {
31
+ const message = { author, text };
32
+ if (timestamp) {
33
+ message.timestamp = timestamp;
34
+ }
35
+ messages.push(message);
36
+ }
37
+ }
38
+ return messages;
39
+ }
40
+ }
@@ -0,0 +1,2 @@
1
+ export type { MessageParser, MessageInput } from './base.js';
2
+ export { DefaultMessageParser } from './default-parser.js';
@@ -0,0 +1 @@
1
+ export { DefaultMessageParser } from './default-parser.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * EventTracker - Utility class for tracking and managing event listeners
3
+ *
4
+ * Provides centralized event listener registration and cleanup,
5
+ * preventing memory leaks by ensuring all listeners are removed when no longer needed.
6
+ */
7
+ export declare class EventTracker {
8
+ private listeners;
9
+ /**
10
+ * Add an event listener and track it for automatic cleanup
11
+ *
12
+ * @param el - Event target element
13
+ * @param type - Event type (e.g., 'scroll', 'click')
14
+ * @param fn - Event listener function
15
+ * @param options - Optional addEventListener options
16
+ */
17
+ add(el: EventTarget, type: string, fn: EventListener, options?: AddEventListenerOptions): void;
18
+ /**
19
+ * Remove all tracked event listeners
20
+ * Should be called when the component is disconnected to prevent memory leaks
21
+ */
22
+ cleanup(): void;
23
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * EventTracker - Utility class for tracking and managing event listeners
3
+ *
4
+ * Provides centralized event listener registration and cleanup,
5
+ * preventing memory leaks by ensuring all listeners are removed when no longer needed.
6
+ */
7
+ export class EventTracker {
8
+ constructor() {
9
+ this.listeners = [];
10
+ }
11
+ /**
12
+ * Add an event listener and track it for automatic cleanup
13
+ *
14
+ * @param el - Event target element
15
+ * @param type - Event type (e.g., 'scroll', 'click')
16
+ * @param fn - Event listener function
17
+ * @param options - Optional addEventListener options
18
+ */
19
+ add(el, type, fn, options) {
20
+ el.addEventListener(type, fn, options);
21
+ this.listeners.push({ el, type, fn, options });
22
+ }
23
+ /**
24
+ * Remove all tracked event listeners
25
+ * Should be called when the component is disconnected to prevent memory leaks
26
+ */
27
+ cleanup() {
28
+ this.listeners.forEach(({ el, type, fn, options }) => {
29
+ el.removeEventListener(type, fn, options);
30
+ });
31
+ this.listeners = [];
32
+ }
33
+ }
@@ -11,7 +11,3 @@ export declare function buildTimestampHtml(timestamp: string, side: 'left' | 'ri
11
11
  * Build a single message row HTML string
12
12
  */
13
13
  export declare function buildMessageRowHtml(author: string, text: string, config: AuthorConfig, isSubsequent: boolean, timestamp?: string, isLastInGroup?: boolean): string;
14
- /**
15
- * Setup tooltip for a single avatar wrapper element
16
- */
17
- export declare function setupTooltipForElement(wrapper: Element): void;
@@ -8,7 +8,6 @@ export function buildAvatarHtml(author, config, showAvatar) {
8
8
  <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
9
9
  data-author="${escapeHtml(author)}">
10
10
  <div class="avatar">${config.avatar}</div>
11
- <div class="avatar-tooltip">${escapeHtml(author)}</div>
12
11
  </div>
13
12
  `;
14
13
  }
@@ -53,17 +52,3 @@ export function buildMessageRowHtml(author, text, config, isSubsequent, timestam
53
52
  </div>
54
53
  `;
55
54
  }
56
- /**
57
- * Setup tooltip for a single avatar wrapper element
58
- */
59
- export function setupTooltipForElement(wrapper) {
60
- wrapper.addEventListener('mouseenter', () => {
61
- const tooltip = wrapper.querySelector('.avatar-tooltip');
62
- if (!tooltip)
63
- return;
64
- const rect = wrapper.getBoundingClientRect();
65
- const tooltipRect = tooltip.getBoundingClientRect();
66
- tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
67
- tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
68
- });
69
- }
@@ -1,5 +1,14 @@
1
1
  /**
2
- * Setup dynamic tooltip positioning
3
- * Tooltips are positioned fixed to avoid overflow clipping from parent containers
2
+ * Tooltip utilities for avatar hover effects
3
+ *
4
+ * Tooltips use position: fixed to escape overflow clipping from scrollable containers.
5
+ * Position is calculated on mouseenter and updated on scroll to prevent staleness.
6
+ */
7
+ /**
8
+ * Setup tooltip positioning for a single avatar wrapper element
9
+ */
10
+ export declare function setupTooltipForElement(wrapper: Element): void;
11
+ /**
12
+ * Setup tooltips for all avatar wrappers in the shadow root
4
13
  */
5
14
  export declare function setupTooltips(shadowRoot: ShadowRoot): void;
@@ -1,17 +1,60 @@
1
1
  /**
2
- * Setup dynamic tooltip positioning
3
- * Tooltips are positioned fixed to avoid overflow clipping from parent containers
2
+ * Tooltip utilities for avatar hover effects
3
+ *
4
+ * Tooltips use position: fixed to escape overflow clipping from scrollable containers.
5
+ * Position is calculated on mouseenter and updated on scroll to prevent staleness.
6
+ */
7
+ /**
8
+ * Position a tooltip relative to its avatar wrapper
9
+ * Centers horizontally and places above the avatar
10
+ */
11
+ function positionTooltip(wrapper, tooltip) {
12
+ const rect = wrapper.getBoundingClientRect();
13
+ const tooltipRect = tooltip.getBoundingClientRect();
14
+ // Center horizontally relative to viewport
15
+ let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
16
+ // Viewport boundary check - keep tooltip within viewport
17
+ const padding = 8;
18
+ left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding));
19
+ // Position above avatar
20
+ const top = rect.top - tooltipRect.height - 8;
21
+ tooltip.style.left = `${left}px`;
22
+ tooltip.style.top = `${top}px`;
23
+ }
24
+ /**
25
+ * Setup tooltip positioning for a single avatar wrapper element
26
+ */
27
+ export function setupTooltipForElement(wrapper) {
28
+ const tooltip = wrapper.querySelector('.avatar-tooltip');
29
+ if (!tooltip)
30
+ return;
31
+ let scrollContainer = null;
32
+ const handleMouseEnter = () => {
33
+ // Find the scrollable container (if any)
34
+ scrollContainer = wrapper.closest('.history');
35
+ // Initial position
36
+ positionTooltip(wrapper, tooltip);
37
+ // Add scroll listener to update position during scroll
38
+ if (scrollContainer) {
39
+ scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
40
+ }
41
+ };
42
+ const handleMouseLeave = () => {
43
+ // Remove scroll listener when not hovering
44
+ if (scrollContainer) {
45
+ scrollContainer.removeEventListener('scroll', handleScroll);
46
+ scrollContainer = null;
47
+ }
48
+ };
49
+ const handleScroll = () => {
50
+ positionTooltip(wrapper, tooltip);
51
+ };
52
+ wrapper.addEventListener('mouseenter', handleMouseEnter);
53
+ wrapper.addEventListener('mouseleave', handleMouseLeave);
54
+ }
55
+ /**
56
+ * Setup tooltips for all avatar wrappers in the shadow root
4
57
  */
5
58
  export function setupTooltips(shadowRoot) {
6
- shadowRoot.querySelectorAll('.avatar-wrapper').forEach(wrapper => {
7
- wrapper.addEventListener('mouseenter', () => {
8
- const tooltip = wrapper.querySelector('.avatar-tooltip');
9
- if (!tooltip)
10
- return;
11
- const rect = wrapper.getBoundingClientRect();
12
- const tooltipRect = tooltip.getBoundingClientRect();
13
- tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
14
- tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
15
- });
16
- });
59
+ shadowRoot.querySelectorAll('.avatar-wrapper').forEach(setupTooltipForElement);
17
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/bb-msg-history",
3
- "version": "0.14.1",
3
+ "version": "2.0.0",
4
4
  "description": "A chat-style message history web component",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",