Skip to content

Commit c243b1e

Browse files
author
Felipe Lang
authored
fix: fix scrolling and improve responsive support
Close #43
1 parent 592dd6b commit c243b1e

File tree

3 files changed

+233
-14
lines changed

3 files changed

+233
-14
lines changed

src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.vaadin.flow.component.icon.VaadinIcon;
3535
import com.vaadin.flow.component.messages.MessageInput;
3636
import com.vaadin.flow.component.messages.MessageInput.SubmitEvent;
37+
import com.vaadin.flow.component.orderedlayout.FlexComponent;
3738
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
3839
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
3940
import com.vaadin.flow.component.popover.Popover;
@@ -44,6 +45,7 @@
4445
import com.vaadin.flow.data.renderer.Renderer;
4546
import com.vaadin.flow.function.SerializableSupplier;
4647
import com.vaadin.flow.shared.Registration;
48+
4749
import java.time.LocalDateTime;
4850
import java.util.ArrayList;
4951
import java.util.List;
@@ -62,6 +64,7 @@
6264
@NpmPackage(value = "@emotion/react", version = "11.14.0")
6365
@NpmPackage(value = "@emotion/styled", version = "11.14.0")
6466
@JsModule("./react/animated-fab.tsx")
67+
@JsModule("./fcChatAssistantConnector.js")
6568
@Tag("animated-fab")
6669
@CssImport("./styles/chat-assistant-styles.css")
6770
public class ChatAssistant<T extends Message> extends ReactAdapterComponent implements ClickNotifier<ChatAssistant<T>> {
@@ -126,8 +129,9 @@ private void initializeHeader() {
126129
@SuppressWarnings("unchecked")
127130
private void initializeFooter() {
128131
messageInput = new MessageInput();
129-
messageInput.setSizeFull();
130-
messageInput.getStyle().set("padding", PADDING_SMALL);
132+
messageInput.setWidthFull();
133+
messageInput.setMaxHeight("80px");
134+
messageInput.getStyle().set("padding", "0");
131135
defaultSubmitListenerRegistration = messageInput.addSubmitListener(se -> {
132136
sendMessage((T) Message.builder().messageTime(LocalDateTime.now())
133137
.name("User").content(se.getValue()).build());
@@ -136,7 +140,7 @@ private void initializeFooter() {
136140
whoIsTyping.setClassName("chat-assistant-who-is-typing");
137141
whoIsTyping.setVisible(false);
138142
VerticalLayout footer = new VerticalLayout(whoIsTyping, messageInput);
139-
footer.setSizeFull();
143+
footer.setWidthFull();
140144
footer.setSpacing(false);
141145
footer.setMargin(false);
142146
footer.setPadding(false);
@@ -151,14 +155,15 @@ private void initializeContent(boolean markdownEnabled) {
151155
return component;
152156
}));
153157
content.setItems(messages);
154-
content.setMinHeight("400px");
155-
content.setMinWidth("400px");
158+
content.setSizeFull();
156159
container = new VerticalLayout(headerComponent, content, footerContainer);
157160
container.setClassName("chat-assistant-container-vertical-layout");
158161
container.setPadding(false);
159162
container.setMargin(false);
160163
container.setSpacing(false);
161164
container.setSizeFull();
165+
container.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
166+
container.setFlexGrow(1, content);
162167
}
163168

164169
private void initializeChatWindow() {
@@ -169,9 +174,10 @@ private void initializeChatWindow() {
169174
chatWindow.add(resizableVL);
170175
chatWindow.setOpenOnClick(false);
171176
chatWindow.setCloseOnOutsideClick(false);
172-
chatWindow.addOpenedChangeListener(ev->{
173-
minimized = !ev.isOpened();
174-
});
177+
chatWindow.addOpenedChangeListener(ev -> minimized = !ev.isOpened());
178+
chatWindow.addAttachListener(e -> e.getUI().getPage()
179+
.executeJs("window.Vaadin.Flow.fcChatAssistantConnector.observePopoverResize($0)", chatWindow.getElement()));
180+
175181
this.getElement().addEventListener("avatar-clicked", ev ->{
176182
if (this.minimized) {
177183
chatWindow.open();
@@ -404,5 +410,5 @@ public int getUnreadMessages() {
404410
public void setUnreadMessages(int unreadMessages) {
405411
setState("unreadMessages",unreadMessages);
406412
}
407-
413+
408414
}

src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@
1919
*/
2020
vaadin-vertical-layout.chat-assistant-resizable-vertical-layout {
2121
transform: rotate(180deg); /* This rotation, along with the one below, is a "double rotation trick." */
22-
margin: -0.125em -0.375em 0;
22+
margin: 0;
23+
padding: 10px;
2324
overflow: hidden;
24-
width: 439px;
2525
resize: both;
26-
min-height: min-content;
27-
min-width: min-content;
26+
min-height: 30vh;
27+
min-width: 310px;
28+
width: var(--fc-chat-assistant-popover-width, 400px);
29+
height: var(--fc-chat-assistant-popover-height, 400px);
2830
}
2931

3032
vaadin-vertical-layout.chat-assistant-container-vertical-layout {
31-
min-height: min-content;
33+
flex-grow: 1;
34+
gap: var(--lumo-space-s);
3235
transform: rotate(180deg); /* This second rotation completes the "double rotation trick."
3336
Together, these two rotations position the resize handle in the upper-left corner.
3437
This new position is more suitable for resizing the chat window because the chat bubble
@@ -37,4 +40,36 @@ vaadin-vertical-layout.chat-assistant-container-vertical-layout {
3740

3841
.MuiBadge-badge {
3942
z-index: 2000 !important;
43+
}
44+
45+
vaadin-popover-overlay::part(content){
46+
padding: 0;
47+
}
48+
49+
vaadin-message::part(message) {
50+
word-break: break-word;
51+
}
52+
53+
vaadin-popover-overlay::part(overlay) {
54+
max-width: 100vw; /* Prevent width beyond viewport */
55+
max-height: 100vh; /* Prevent height beyond viewport */
56+
}
57+
58+
/* Mobile breakpoint */
59+
@media (max-width: 768px) {
60+
vaadin-popover-overlay::part(overlay) {
61+
width: 100%;
62+
height: 100%;
63+
}
64+
65+
vaadin-popover-overlay::part(content) {
66+
width: 100%;
67+
height: 100%;
68+
}
69+
70+
vaadin-vertical-layout.chat-assistant-resizable-vertical-layout {
71+
resize: none;
72+
height: 100%;
73+
width: 100%;
74+
}
4075
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*-
2+
* #%L
3+
* Chat Assistant Add-on
4+
* %%
5+
* Copyright (C) 2025 Flowing Code
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
(function () {
21+
window.Vaadin.Flow.fcChatAssistantConnector = {
22+
observePopoverResize: (popover) => {
23+
// Skip the following logic on mobile devices by checking viewport width.
24+
if (window.innerWidth <= 768) {
25+
return;
26+
}
27+
28+
if (popover.$connector) {
29+
return;
30+
}
31+
32+
popover.$connector = {};
33+
34+
// Find the resizable container inside the popover
35+
const resizableContainer = popover.querySelector('.chat-assistant-resizable-vertical-layout');
36+
if (!resizableContainer) return;
37+
38+
popover.addEventListener('opened-changed', e => {
39+
if (e.detail.value) {
40+
const popoverOverlay = resizableContainer.parentElement;
41+
const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]');
42+
// Track overlay position changes and keep container inside viewport
43+
trackOverlayPosition(overlay, resizableContainer, () => clampToViewport(resizableContainer));
44+
}
45+
});
46+
47+
// On drag/resize start (mouse), reset size restrictions so user can freely resize
48+
resizableContainer.addEventListener("mousedown", e => {
49+
resizableContainer.style.maxHeight = '';
50+
resizableContainer.style.maxWidth = '';
51+
});
52+
// On drag/resize start (touch), reset size restrictions so user can freely resize
53+
resizableContainer.addEventListener("touchstart", e => {
54+
resizableContainer.style.maxHeight = '';
55+
resizableContainer.style.maxWidth = '';
56+
});
57+
58+
// Debounce calls to avoid excessive recalculations on rapid resize
59+
const debouncedClamp = debounce(() => clampToViewport(resizableContainer));
60+
61+
new ResizeObserver(() => {
62+
const popoverOverlay = resizableContainer.parentElement;
63+
const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]');
64+
if (!overlay) return;
65+
66+
debouncedClamp();
67+
}).observe(resizableContainer);
68+
69+
70+
function debounce(callback) {
71+
let rafId;
72+
return () => {
73+
cancelAnimationFrame(rafId);
74+
rafId = requestAnimationFrame(callback);
75+
};
76+
}
77+
78+
/**
79+
* Restricts the size and position of a resizable container so that it remains fully visible
80+
* within the browser's viewport, applying a small padding to keep it from touching the edges.
81+
*
82+
* This function calculates how much space is available on each side of the container
83+
* (top, bottom, left, right) relative to the viewport. If the container would overflow
84+
* on a given side, it adjusts `maxWidth`/`maxHeight` and aligns it to the opposite side
85+
* with a fixed padding.
86+
*
87+
* - If there isn't enough space on the right, it clamps width and aligns to the left.
88+
* - If there isn't enough space on the left, it clamps width and aligns to the right.
89+
* - If there isn't enough space at the bottom, it clamps height and aligns to the top.
90+
* - If there isn't enough space at the top, it clamps height and aligns to the bottom.
91+
*
92+
* @param {HTMLElement} resizableContainer - The element whose size and position should be clamped to the viewport.
93+
*/
94+
function clampToViewport(resizableContainer) {
95+
const boundingClientRect = resizableContainer.getBoundingClientRect();
96+
97+
const containerWidthRight = boundingClientRect.width + (window.innerWidth - boundingClientRect.right);
98+
const containerWidthLeft = boundingClientRect.left + boundingClientRect.width;
99+
const containerHeightBottom = boundingClientRect.height + (window.innerHeight - boundingClientRect.bottom);
100+
const containerHeightTop = boundingClientRect.top + boundingClientRect.height;
101+
102+
const padding = 5;
103+
const paddingPx = padding + "px";
104+
105+
if (containerWidthRight >= window.innerWidth) {
106+
resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px";
107+
resizableContainer.style.left = paddingPx;
108+
} else if (containerWidthLeft >= window.innerWidth) {
109+
resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px";
110+
resizableContainer.style.right = paddingPx;
111+
}
112+
113+
if (containerHeightBottom >= window.innerHeight) {
114+
resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px";
115+
resizableContainer.style.top = paddingPx;
116+
} else if (containerHeightTop >= window.innerHeight) {
117+
resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px";
118+
resizableContainer.style.bottom = paddingPx;
119+
}
120+
}
121+
122+
/**
123+
* Continuously tracks the position of an overlay element and triggers a callback
124+
* when the overlay's position has stabilized (i.e., changes are within the given buffer).
125+
*
126+
* This function uses `requestAnimationFrame` to check the overlay's position every frame.
127+
* If the overlay moves more than `positionBuffer` pixels horizontally or vertically,
128+
* tracking continues without calling the callback.
129+
* Once the position changes are smaller than `positionBuffer`, the callback is invoked.
130+
*
131+
* @param {HTMLElement} overlay - The overlay element to track. Must support `.checkVisibility()`.
132+
* @param {HTMLElement} resizableContainer - The container related to the overlay (not used directly here,
133+
* but often used by the callback to adjust size).
134+
* @param {Function} callback - Function to call when the overlay position is stable.
135+
* @param {number} [positionBuffer=10] - The minimum pixel movement threshold before considering the overlay stable.
136+
*/
137+
function trackOverlayPosition(overlay, resizableContainer, callback, positionBuffer = 10) {
138+
let lastTop = 0;
139+
let lastLeft = 0;
140+
let frameId;
141+
142+
function checkPosition() {
143+
if (!isVisible(overlay)) {
144+
cancelAnimationFrame(frameId);
145+
return;
146+
}
147+
148+
const rect = overlay.getBoundingClientRect();
149+
const deltaTop = Math.abs(rect.top - lastTop);
150+
const deltaLeft = Math.abs(rect.left - lastLeft);
151+
if (deltaTop > positionBuffer || deltaLeft > positionBuffer) {
152+
lastTop = rect.top;
153+
lastLeft = rect.left;
154+
} else {
155+
callback();
156+
}
157+
158+
frameId = requestAnimationFrame(checkPosition);
159+
}
160+
161+
frameId = requestAnimationFrame(checkPosition);
162+
}
163+
164+
function isVisible(el) {
165+
if (!el) return false;
166+
167+
if (typeof el.checkVisibility === 'function') {
168+
// Use native checkVisibility if available
169+
return el.checkVisibility();
170+
}
171+
172+
// Fallback: check CSS display and visibility
173+
const style = getComputedStyle(el);
174+
return style.display !== 'none' && style.visibility !== 'hidden';
175+
}
176+
},
177+
}
178+
})();

0 commit comments

Comments
 (0)