Skip to content

Commit 0d4e42d

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

File tree

3 files changed

+218
-14
lines changed

3 files changed

+218
-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: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
new ResizeObserver(() => {
59+
const popoverOverlay = resizableContainer.parentElement;
60+
const overlay = popoverOverlay.shadowRoot?.querySelector('[part="overlay"]');
61+
if (!overlay) return;
62+
63+
// Debounce calls to avoid excessive recalculations on rapid resize
64+
debounce(() => clampToViewport(resizableContainer));
65+
}).observe(resizableContainer);
66+
67+
68+
function debounce(callback) {
69+
let rafId;
70+
return () => {
71+
cancelAnimationFrame(rafId);
72+
rafId = requestAnimationFrame(callback);
73+
};
74+
}
75+
76+
/**
77+
* Restricts the size and position of a resizable container so that it remains fully visible
78+
* within the browser's viewport, applying a small padding to keep it from touching the edges.
79+
*
80+
* This function calculates how much space is available on each side of the container
81+
* (top, bottom, left, right) relative to the viewport. If the container would overflow
82+
* on a given side, it adjusts `maxWidth`/`maxHeight` and aligns it to the opposite side
83+
* with a fixed padding.
84+
*
85+
* - If there isn't enough space on the right, it clamps width and aligns to the left.
86+
* - If there isn't enough space on the left, it clamps width and aligns to the right.
87+
* - If there isn't enough space at the bottom, it clamps height and aligns to the top.
88+
* - If there isn't enough space at the top, it clamps height and aligns to the bottom.
89+
*
90+
* @param {HTMLElement} resizableContainer - The element whose size and position should be clamped to the viewport.
91+
*/
92+
function clampToViewport(resizableContainer) {
93+
const boundingClientRect = resizableContainer.getBoundingClientRect();
94+
95+
const containerWidthRight = boundingClientRect.width + (window.innerWidth - boundingClientRect.right);
96+
const containerWidthLeft = boundingClientRect.left + boundingClientRect.width;
97+
const containerHeightBottom = boundingClientRect.height + (window.innerHeight - boundingClientRect.bottom);
98+
const containerHeightTop = boundingClientRect.top + boundingClientRect.height;
99+
100+
const padding = 5;
101+
const paddingPx = padding + "px";
102+
103+
if (containerWidthRight >= window.innerWidth) {
104+
resizableContainer.style.maxWidth = (boundingClientRect.right - padding) + "px";
105+
resizableContainer.style.left = paddingPx;
106+
} else if (containerWidthLeft >= window.innerWidth) {
107+
resizableContainer.style.maxWidth = (window.innerWidth - boundingClientRect.left - padding) + "px";
108+
resizableContainer.style.right = paddingPx;
109+
}
110+
111+
if (containerHeightBottom >= window.innerHeight) {
112+
resizableContainer.style.maxHeight = (boundingClientRect.bottom - padding) + "px";
113+
resizableContainer.style.top = paddingPx;
114+
} else if (containerHeightTop >= window.innerHeight) {
115+
resizableContainer.style.maxHeight = (window.innerHeight - boundingClientRect.top - padding) + "px";
116+
resizableContainer.style.bottom = paddingPx;
117+
}
118+
}
119+
120+
/**
121+
* Continuously tracks the position of an overlay element and triggers a callback
122+
* when the overlay's position has stabilized (i.e., changes are within the given buffer).
123+
*
124+
* This function uses `requestAnimationFrame` to check the overlay's position every frame.
125+
* If the overlay moves more than `positionBuffer` pixels horizontally or vertically,
126+
* tracking continues without calling the callback.
127+
* Once the position changes are smaller than `positionBuffer`, the callback is invoked.
128+
*
129+
* @param {HTMLElement} overlay - The overlay element to track. Must support `.checkVisibility()`.
130+
* @param {HTMLElement} resizableContainer - The container related to the overlay (not used directly here,
131+
* but often used by the callback to adjust size).
132+
* @param {Function} callback - Function to call when the overlay position is stable.
133+
* @param {number} [positionBuffer=10] - The minimum pixel movement threshold before considering the overlay stable.
134+
*/
135+
function trackOverlayPosition(overlay, resizableContainer, callback, positionBuffer = 10) {
136+
let lastTop = 0;
137+
let lastLeft = 0;
138+
let frameId;
139+
140+
function checkPosition() {
141+
if (!overlay.checkVisibility()) {
142+
cancelAnimationFrame(frameId);
143+
return;
144+
}
145+
146+
const rect = overlay.getBoundingClientRect();
147+
const deltaTop = Math.abs(rect.top - lastTop);
148+
const deltaLeft = Math.abs(rect.left - lastLeft);
149+
if (deltaTop > positionBuffer || deltaLeft > positionBuffer) {
150+
lastTop = rect.top;
151+
lastLeft = rect.left;
152+
} else {
153+
callback();
154+
}
155+
156+
frameId = requestAnimationFrame(checkPosition);
157+
}
158+
159+
frameId = requestAnimationFrame(checkPosition);
160+
}
161+
},
162+
}
163+
})();

0 commit comments

Comments
 (0)