@bbki.ng/bb-msg-history 0.14.1 → 1.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.
@@ -0,0 +1,204 @@
1
+ import { EMPTY_STYLES, LOADING_STYLES, MAIN_STYLES } from '../const/styles.js';
2
+ import { resolveAuthorConfig } from '../utils/author-resolver.js';
3
+ import { buildMessageRowHtml, setupTooltipForElement } from '../utils/message-builder.js';
4
+ import { buildScrollButtonHtml } from '../utils/scroll-button.js';
5
+ import { setupTooltips } from '../utils/tooltip.js';
6
+ /**
7
+ * Renderer - Manages all DOM rendering operations
8
+ *
9
+ * Centralizes DOM manipulation logic:
10
+ * - Full render of all messages
11
+ * - Incremental update when appending single messages
12
+ * - Empty state rendering
13
+ * - Loading overlay management
14
+ */
15
+ export class Renderer {
16
+ constructor(shadowRoot) {
17
+ this.shadowRoot = shadowRoot;
18
+ }
19
+ /**
20
+ * Render the complete message history
21
+ *
22
+ * @param messages - Processed messages with grouping metadata
23
+ * @param authors - User-defined author configurations
24
+ * @param isLoading - Whether to show loading overlay
25
+ * @param hideScrollButton - Whether to hide the scroll-to-bottom button
26
+ * @returns Result indicating if we were at bottom before render
27
+ */
28
+ render(messages, authors, isLoading, hideScrollButton) {
29
+ // Check if we need to create or update the structure
30
+ const historyContainer = this.shadowRoot.querySelector('.history');
31
+ const needsFullSetup = !historyContainer;
32
+ // Build messages HTML
33
+ const messagesHtml = this.buildMessagesHtml(messages, authors);
34
+ if (needsFullSetup) {
35
+ // First render - create full structure
36
+ return this.renderFullStructure(messagesHtml, isLoading, hideScrollButton);
37
+ }
38
+ else {
39
+ // Update only - preserve DOM structure
40
+ return this.updateContent(historyContainer, messagesHtml, isLoading);
41
+ }
42
+ }
43
+ /**
44
+ * Build HTML string for all messages
45
+ */
46
+ buildMessagesHtml(messages, authors) {
47
+ return messages
48
+ .map(msg => {
49
+ const config = resolveAuthorConfig(msg.author, authors);
50
+ return buildMessageRowHtml(msg.author, msg.text, config, !msg.isFirstFromAuthor, // isSubsequent
51
+ msg.groupTimestamp, msg.isLastInGroup);
52
+ })
53
+ .join('');
54
+ }
55
+ /**
56
+ * Render full structure including styles, container, and scroll button
57
+ */
58
+ renderFullStructure(messagesHtml, isLoading, hideScrollButton) {
59
+ // For initial render, we consider it as "was at bottom" to scroll down
60
+ const wasAtBottom = true;
61
+ const loadingOverlay = isLoading
62
+ ? `<div class="loading-overlay" role="status" aria-label="Loading messages">
63
+ <div class="loading-spinner"></div>
64
+ </div>`
65
+ : '';
66
+ this.shadowRoot.innerHTML = `
67
+ <style>${MAIN_STYLES}${LOADING_STYLES}</style>
68
+ <div class="history" role="log" aria-live="polite" aria-label="Message history">
69
+ ${messagesHtml}
70
+ </div>
71
+ ${hideScrollButton ? '' : buildScrollButtonHtml()}
72
+ ${loadingOverlay}
73
+ `;
74
+ return { wasAtBottom };
75
+ }
76
+ /**
77
+ * Update content while preserving DOM structure
78
+ */
79
+ updateContent(historyContainer, messagesHtml, isLoading) {
80
+ // Check scroll position before update
81
+ const scrollContainer = historyContainer;
82
+ const wasAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
83
+ // Update messages content only
84
+ historyContainer.innerHTML = messagesHtml;
85
+ // Update loading overlay
86
+ this.updateLoadingOverlay(isLoading);
87
+ // Restore scroll position or scroll to bottom if we were there
88
+ if (wasAtBottom) {
89
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
90
+ }
91
+ // Re-setup tooltips for new content
92
+ setupTooltips(this.shadowRoot);
93
+ return { wasAtBottom };
94
+ }
95
+ /**
96
+ * Append a single message without full re-render
97
+ *
98
+ * @param message - The message to append
99
+ * @param authors - User-defined author configurations
100
+ * @param lastState - Previous state for grouping logic
101
+ * @returns Result indicating success and updated state
102
+ */
103
+ appendSingleMessage(message, authors, lastState) {
104
+ const container = this.shadowRoot.querySelector('.history');
105
+ // If empty state or no container, signal that full render is needed
106
+ if (!container) {
107
+ return { success: false, lastAuthor: lastState.author };
108
+ }
109
+ const config = resolveAuthorConfig(message.author, authors);
110
+ // Determine grouping
111
+ const prevMessage = lastState.author
112
+ ? { author: lastState.author, text: '', timestamp: lastState.groupTimestamp }
113
+ : null;
114
+ const canGroupWithLast = this.canGroupMessages(prevMessage, message);
115
+ const isFirstFromAuthor = !canGroupWithLast;
116
+ // Calculate new state
117
+ let lastGroupTimestamp = lastState.groupTimestamp;
118
+ if (isFirstFromAuthor) {
119
+ lastGroupTimestamp = message.timestamp;
120
+ }
121
+ else if (!lastGroupTimestamp && message.timestamp) {
122
+ lastGroupTimestamp = message.timestamp;
123
+ }
124
+ // Build and append HTML
125
+ const msgHtml = buildMessageRowHtml(message.author, message.text, config, !isFirstFromAuthor, // isSubsequent
126
+ lastGroupTimestamp, true // isLastInGroup - when appending, this is always last (for now)
127
+ );
128
+ container.insertAdjacentHTML('beforeend', msgHtml);
129
+ // Setup tooltip for new element
130
+ const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
131
+ if (newWrapper) {
132
+ setupTooltipForElement(newWrapper);
133
+ }
134
+ return {
135
+ success: true,
136
+ lastAuthor: message.author,
137
+ lastGroupTimestamp,
138
+ };
139
+ }
140
+ /**
141
+ * Check if two messages can be grouped
142
+ */
143
+ canGroupMessages(prev, curr) {
144
+ if (!prev)
145
+ return false;
146
+ if (prev.author !== curr.author)
147
+ return false;
148
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
149
+ return false;
150
+ }
151
+ return true;
152
+ }
153
+ /**
154
+ * Render empty state (no messages)
155
+ */
156
+ renderEmpty(isLoading) {
157
+ if (isLoading) {
158
+ // Show loading overlay with minimum height
159
+ this.shadowRoot.innerHTML = `
160
+ <style>${EMPTY_STYLES}${LOADING_STYLES}</style>
161
+ <div style="position: relative; min-height: 120px;">
162
+ <div class="loading-overlay" role="status" aria-label="Loading messages">
163
+ <div class="loading-spinner"></div>
164
+ </div>
165
+ </div>
166
+ `;
167
+ }
168
+ else {
169
+ this.shadowRoot.innerHTML = `
170
+ <style>${EMPTY_STYLES}</style>
171
+ <div class="empty-state">No messages</div>
172
+ `;
173
+ }
174
+ }
175
+ /**
176
+ * Update loading overlay visibility
177
+ */
178
+ updateLoadingOverlay(shouldShow) {
179
+ const existingOverlay = this.shadowRoot.querySelector('.loading-overlay');
180
+ if (shouldShow && !existingOverlay) {
181
+ const overlay = document.createElement('div');
182
+ overlay.className = 'loading-overlay';
183
+ overlay.setAttribute('role', 'status');
184
+ overlay.setAttribute('aria-label', 'Loading messages');
185
+ overlay.innerHTML = '<div class="loading-spinner"></div>';
186
+ this.shadowRoot.appendChild(overlay);
187
+ }
188
+ else if (!shouldShow && existingOverlay) {
189
+ existingOverlay.remove();
190
+ }
191
+ }
192
+ /**
193
+ * Get the history container element
194
+ */
195
+ getHistoryContainer() {
196
+ return this.shadowRoot.querySelector('.history');
197
+ }
198
+ /**
199
+ * Get the scroll-to-bottom button element
200
+ */
201
+ getScrollButton() {
202
+ return this.shadowRoot.querySelector('.scroll-to-bottom');
203
+ }
204
+ }
@@ -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,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
+ }
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": "1.0.0",
4
4
  "description": "A chat-style message history web component",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",