diff --git a/.gitignore b/.gitignore index de849b2..96e6271 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ drivers /types.d.ts* /frontend/generated /frontend/index.html +/frontend/web-component.html +/src/main/frontend/web-component.html /src/main/frontend/generated /src/main/frontend/index.html vite.generated.ts diff --git a/README.md b/README.md index 9b0cdc9..a99ea5f 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,16 @@ Vaadin Add-on that displays a chat assistant floating window using [Material UI' ## Features -* Messages can be sent by the user or programmatically. -* Listen for new messages written by the user. -* Toggle the chat window on/off. +* Send messages from the user or programmatically, and listen for messages written by the user. +* Toggle the chat window open/closed, or open it as a full-screen dialog on mobile. +* Markdown rendering, lazy loading via a `DataProvider`, and streaming ("generative") answers. +* Customizable floating action button (FAB): icon, size, color (`ButtonVariant` theme variants), + corner position and margin, draggable or fixed, and viewport- or container-anchored placement. +* Resizable chat window with eight drag handles, optional resize direction indicators, and + configurable initial size with min/max bounds. +* Responsive desktop/mobile modes with optional breakpoint-based auto-switching, plus listeners for + mode changes and for the chat window crossing a size threshold. +* A fluent `ChatAssistant.builder()` to configure everything declaratively. ## Supported versions @@ -96,24 +103,79 @@ Chat Assistant Add-on is written by Flowing Code S.A. ## Getting started -Simple example showing the basic options: - - ChatAssistant chatAssistant = new ChatAssistant(); - TextArea message = new TextArea(); - message.setLabel("Enter a message from the assistant"); - message.setSizeFull(); - - Button chat = new Button("Chat"); - chat.addClickListener(ev->{ - Message m = new Message(message.getValue(),false,false,0,false,new Sender("Assistant","1","https://ui-avatars.com/api/?name=Bot")); - chatAssistant.sendMessage(m); - message.clear(); - }); - chatAssistant.sendMessage(new Message("Hello, I am here to assist you",false,false,0,false,new Sender("Assistant","1","https://ui-avatars.com/api/?name=Bot"))); - chatAssistant.toggle(); - chatAssistant.addChatSentListener(ev->{ - Notification.show(ev.getMessage()); - }); +`ChatAssistant` renders a floating action button (FAB) that opens a chat window. +Add it to any layout and send messages to it; it shows a FAB in the bottom-right corner by default. + +```java +ChatAssistant chatAssistant = new ChatAssistant<>(); +add(chatAssistant); + +// Send a message programmatically (e.g. from the assistant). +chatAssistant.sendMessage(Message.builder() + .name("Assistant") + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .build()); + +// React to messages typed by the user. +chatAssistant.setSubmitListener(ev -> + chatAssistant.sendMessage(Message.builder() + .name("User") + .content(ev.getValue()) + .messageTime(LocalDateTime.now()) + .build())); + +// Open or close the window programmatically. +chatAssistant.setOpened(true); +``` + +### Configuring with the builder + +Use `ChatAssistant.builder()` to configure the FAB and window declaratively: + +```java +ChatAssistant chatAssistant = ChatAssistant.builder() + .fabIcon(new SvgIcon("icons/my-icon.svg")) // custom FAB icon (defaults to a chatbot icon) + .defaultFabPosition(FabPosition.BOTTOM_LEFT) + .fabMovable(true) // allow dragging the FAB + .resizable(true) // allow resizing the window + .markdownEnabled(true) // render message content as Markdown + .build(); +``` + +Everything in the builder also has a setter, so the FAB and window can be reconfigured at runtime. + +### Styling the FAB + +```java +chatAssistant.setFabPosition(FabPosition.TOP_RIGHT); // move it (and set the reset corner) +chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_LARGE); // grow the FAB (LUMO_SMALL/LUMO_LARGE) +chatAssistant.addFabThemeVariants(ButtonVariant.LUMO_CONTRAST); // recolor it (color variants) +chatAssistant.setResizeIndicatorsVisible(true); // show resize-direction hints +``` + +### Sizing the window + +```java +chatAssistant.setWindowWidth("400px"); +chatAssistant.setWindowHeight("500px"); +chatAssistant.setWindowMinWidth(300); // bounds honored on open and while resizing +chatAssistant.setWindowMaxWidth(700); +``` + +### Responsive / mobile mode + +In `MOBILE` mode the chat opens as a full-screen dialog. Set a breakpoint to switch automatically +between desktop (anchored popover) and mobile as the viewport crosses it: + +```java +ChatAssistant chatAssistant = ChatAssistant.builder() + .mobileBreakpoint(768) // auto-switch to mobile below 768px (auto-switching is opt-in) + .build(); + +chatAssistant.addModeChangedListener(ev -> Notification.show("Now in " + ev.getMode() + " mode")); +chatAssistant.setMode(ChatAssistantMode.MOBILE); // or switch manually +``` ## Special configuration when using Spring diff --git a/pom.xml b/pom.xml index 599667a..0da1f01 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.vaadin.addons.flowingcode chat-assistant-addon - 5.0.2-SNAPSHOT + 5.1.0-SNAPSHOT Chat Assistant Add-on Chat Assistant Add-on for Vaadin Flow https://www.flowingcode.com/en/open-source/ @@ -20,7 +20,6 @@ ${drivers.dir}/chromedriver 11.0.26 5.2.0 - 2.0.3 1.18.34 1.3 3.1.0 @@ -125,11 +124,6 @@ vaadin-core true - - com.flowingcode.vaadin.addons - markdown-editor-addon - ${markdown-editor.version} - org.projectlombok lombok diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 16d85a4..c3a3133 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -2,14 +2,14 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -19,24 +19,31 @@ */ package com.flowingcode.vaadin.addons.chatassistant; +import com.flowingcode.vaadin.addons.chatassistant.model.ChatAssistantMode; +import com.flowingcode.vaadin.addons.chatassistant.model.FabPosition; +import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; import com.vaadin.flow.component.AttachEvent; import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.SvgIcon; import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.messages.MessageInput; -import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.popover.Popover; @@ -46,19 +53,32 @@ import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.renderer.Renderer; import com.vaadin.flow.dom.Style; +import com.vaadin.flow.dom.Style.AlignItems; +import com.vaadin.flow.dom.Style.AlignSelf; +import com.vaadin.flow.dom.Style.Display; +import com.vaadin.flow.dom.Style.Position; import com.vaadin.flow.function.SerializableSupplier; import com.vaadin.flow.shared.Registration; - +import java.io.IOException; +import java.io.InputStream; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import lombok.Builder; /** * Component that allows to create a floating chat button that will open a chat window that can be * used to provide a chat assistant feature. * + *

Only one {@code ChatAssistant} is supported per {@link UI}: the FAB and chat window are + * UI-level surfaces, so attaching a second instance to the same UI throws an {@link + * IllegalStateException}. + * * @author mmlopez */ @JsModule("./fc-chat-assistant-movement.js") @@ -67,12 +87,34 @@ @Tag("animated-fab") public class ChatAssistant extends Div { - protected SvgIcon fabIcon; + protected SvgIcon fabIcon = createDefaultFabIcon(); + // The icon component currently shown in the FAB; the single source of truth for icon sizing + // (fabIcon is + // SvgIcon-typed and becomes null for non-SvgIcon icons, so it can't be used for that). + protected Component fabIconComponent = fabIcon; + protected boolean resizable = DEFAULT_WINDOW_RESIZABLE; + protected boolean fabMovable = DEFAULT_FAB_MOVABLE; + // The desktop movable preference, remembered so it can be restored when leaving mobile mode + // (which + // always forces the FAB non-movable). fabMovable itself always reflects the true current state. + protected boolean desktopFabMovablePreference = DEFAULT_FAB_MOVABLE; + protected ChatAssistantMode mode = DEFAULT_MODE; + protected int mobileBreakpoint = DEFAULT_MOBILE_BREAKPOINT; + protected boolean mobileModeSwitchingEnabled = false; + protected boolean fabAnchoredToViewport = DEFAULT_FAB_ANCHORED_TO_VIEWPORT; + protected boolean resizeIndicatorsVisible = DEFAULT_RESIZE_INDICATORS_VISIBLE; + protected FabPosition fabPosition = DEFAULT_POSITION; + protected int fabMargin = DEFAULT_FAB_MARGIN; + protected int fabSize = DEFAULT_FAB_SIZE; + protected FabVariant activeSizeVariant = null; + protected String minWidth = DEFAULT_CONTENT_MIN_WIDTH + "px"; + protected String minHeight = DEFAULT_CONTENT_MIN_HEIGHT + "px"; protected final Button fab = new Button(); protected final Div unreadBadge = new Div(); protected final Div fabWrapper = new Div(fab, unreadBadge); protected final Popover chatWindow = new Popover(); + protected final Dialog mobileChatWindow = new Dialog(); protected final Div overlay = new Div(); protected final VerticalLayout container = new VerticalLayout(); @@ -86,48 +128,106 @@ public class ChatAssistant extends Div { protected final Div resizerTopLeft = new Div(); protected static final int DEFAULT_FAB_SIZE = 60; + protected static final int DEFAULT_FAB_SMALL_SIZE = 50; + protected static final int DEFAULT_FAB_LARGE_SIZE = 72; + protected static final boolean DEFAULT_FAB_ANCHORED_TO_VIEWPORT = true; + protected static final boolean DEFAULT_RESIZE_INDICATORS_VISIBLE = false; + protected static final boolean DEFAULT_WINDOW_RESIZABLE = true; + protected static final boolean DEFAULT_FAB_MOVABLE = true; + protected static final ChatAssistantMode DEFAULT_MODE = ChatAssistantMode.DESKTOP; + protected static final int DEFAULT_MOBILE_BREAKPOINT = 768; protected static final int DEFAULT_FAB_ICON_SIZE = 40; protected static final int DEFAULT_FAB_MARGIN = 25; protected static final int DEFAULT_RESIZER_SIZE = 25; protected static final int DEFAULT_MAX_RESIZER_SIZE = 200; protected static final int DEFAULT_DRAG_SENSITIVITY = 25; + protected static final FabPosition DEFAULT_POSITION = FabPosition.BOTTOM_RIGHT; + // Theme-agnostic token fallback chains (Lumo token, then Aura token, then a literal) so the badge + // renders correctly under both the Lumo and Aura themes of Vaadin 25. + protected static final String DEFAULT_UNREAD_BADGE_BACKGROUND = + "var(--lumo-warning-color, var(--aura-accent-yellow, #e07a00))"; + protected static final String DEFAULT_UNREAD_BADGE_COLOR = + "var(--lumo-warning-contrast-color, var(--aura-accent-contrast-color, #ffffff))"; + // Shared spacing/typography tokens used for the badge, with the same cross-theme fallback pattern. + protected static final String BADGE_FONT_SIZE = + "var(--lumo-font-size-xs, var(--aura-font-size-s, 0.75rem))"; + protected static final String BADGE_PADDING = + "var(--lumo-space-xs, var(--vaadin-padding-xs, 0.25rem))"; + + protected static final int DEFAULT_CONTENT_MIN_WIDTH = 150; + protected static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; + protected static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; + protected static final String DEFAULT_DIALOG_TAG = "fc-chat-assistant-dialog"; + protected static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; + protected static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; + protected static final String RESIZE_INDICATOR_VISIBLE_CLASS = + "fc-chat-assistant-resize-indicator-visible"; + protected static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; + // Key under which the active ChatAssistant is tracked on its UI to enforce a single instance per + // UI. + private static final String UI_INSTANCE_KEY = "fc-chat-assistant-ui-instance"; + // Custom CSS properties that carry the chat window size constraints to the resize frontend. + protected static final String CSS_MIN_WIDTH = "--fc-min-width"; + protected static final String CSS_MIN_HEIGHT = "--fc-min-height"; + protected static final String CSS_MAX_WIDTH = "--fc-max-width"; + protected static final String CSS_MAX_HEIGHT = "--fc-max-height"; + protected static final String DEFAULT_FAB_ICON_SRC = loadDefaultFabIconSrc(); + + protected final VirtualList content = new VirtualList<>(); + protected final List messages = new ArrayList<>(); + + protected Component headerComponent; + protected Component footerContainer; + protected MessageInput messageInput; + protected Span whoIsTyping; + protected Registration defaultSubmitListenerRegistration; + protected int unreadMessages = 0; + + private int screenSizeKeySeq = 0; + private final Map screenSizeListeners = new HashMap<>(); - private static final int DEFAULT_CONTENT_MIN_WIDTH = 150; - private static final int DEFAULT_CONTENT_MIN_HEIGHT = 150; - private static final String DEFAULT_POPOVER_TAG = "fc-chat-assistant-popover"; - private static final String DEFAULT_FAB_CLASS = "fc-chat-assistant-fab"; - private static final String DEFAULT_RESIZE_CLASS = "fc-chat-assistant-resize"; - private static final String DEFAULT_UNREAD_BADGE_CLASS = "fc-chat-assistant-unread-badge"; - - private Component headerComponent; - private Component footerContainer; - private final VirtualList content; - private final List messages; - private MessageInput messageInput; - private Span whoIsTyping; - private Registration defaultSubmitListenerRegistration; - private int unreadMessages = 0; - + /** + * Creates a ChatAssistant with the given initial messages, using the defaults for every other + * setting. The messages are copied; later changes to the supplied list are not reflected. + * + *

To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. + * + * @param messages the initial messages + * @param markdownEnabled flag to enable or disable markdown support + */ public ChatAssistant(List messages, boolean markdownEnabled) { - this.setUI(); - - this.content = new VirtualList<>(); - this.messages = messages; - this.initializeHeader(); - this.initializeFooter(); - this.initializeContent(markdownEnabled); - this.initializeChatWindow(); + this( + null, + null, + null, + null, + null, + null, + markdownEnabled, + messages, + null, + null, + null, + null, + null, + null, + null, + null); } /** - * Default constructor. Creates a ChatAssistant with no messages. + * Creates a ChatAssistant with no messages, using the defaults for every setting. + * + *

To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. */ public ChatAssistant() { this(new ArrayList<>(), false); } /** - * Creates a ChatAssistant with no messages. + * Creates a ChatAssistant with no messages, using the defaults for every other setting. + * + *

To configure multiple aspects at construction time, prefer {@code ChatAssistant.builder()}. * * @param markdownEnabled flag to enable or disable markdown support */ @@ -135,63 +235,153 @@ public ChatAssistant(boolean markdownEnabled) { this(new ArrayList<>(), markdownEnabled); } - @Override - protected void onAttach(AttachEvent attachEvent) { - super.onAttach(attachEvent); - addComponentRefreshedListener( - "fc-chat-assistant-drag-listener", - "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", - this.getElement(), fabWrapper.getElement(), overlay, fab.getElement(), DEFAULT_FAB_MARGIN, - DEFAULT_DRAG_SENSITIVITY - - ); + /** + * Builder constructor. Every configurable aspect is optional; when a value is {@code null} or + * invalid it falls back to its corresponding default. + * + * @param fabIcon the FAB icon ({@code null} keeps the default chatbot icon) + * @param resizable whether the chat window is resizable (default {@value + * #DEFAULT_WINDOW_RESIZABLE}) + * @param fabMovable whether the FAB can be dragged (default {@value #DEFAULT_FAB_MOVABLE}) + * @param mobileBreakpoint the maximum screen width in pixels below which mobile mode is activated + * automatically. Providing any non-null value enables automatic switching (disabled by + * default); {@code null} leaves it disabled, and {@code 0} enables switching but keeps the + * component in desktop mode at any width + * @param fabAnchoredToViewport whether the FAB is anchored to the viewport ({@code true}, the + * default) or positioned within its container ({@code false}) (default {@value + * #DEFAULT_FAB_ANCHORED_TO_VIEWPORT}) + * @param resizeIndicatorsVisible whether the resize handles show a direction arrowhead (default + * {@value #DEFAULT_RESIZE_INDICATORS_VISIBLE}) + * @param markdownEnabled whether markdown is enabled in messages + * @param messages the initial messages ({@code null} starts empty) + * @param defaultFabPosition the FAB's initial corner (default {@link FabPosition#BOTTOM_RIGHT}) + * @param defaultFabMargin the FAB's margin to the viewport edges in pixels (default {@value + * #DEFAULT_FAB_MARGIN}) + * @param minWidth the chat window minimum width ({@code null} keeps the default) + * @param minHeight the chat window minimum height ({@code null} keeps the default) + * @param width the chat window's initial width as an absolute CSS length ({@code null} keeps the + * default) + * @param height the chat window's initial height as an absolute CSS length ({@code null} keeps + * the default) + * @param maxWidth the chat window maximum width ({@code null} keeps the default) + * @param maxHeight the chat window maximum height ({@code null} keeps the default) + */ + @Builder + public ChatAssistant( + SvgIcon fabIcon, + Boolean resizable, + Boolean fabMovable, + Integer mobileBreakpoint, + Boolean fabAnchoredToViewport, + Boolean resizeIndicatorsVisible, + Boolean markdownEnabled, + List messages, + FabPosition defaultFabPosition, + String defaultFabMargin, + String minWidth, + String minHeight, + String width, + String height, + String maxWidth, + String maxHeight) { + if (messages != null) { + this.messages.addAll(messages); + } + this.setUI( + fabIcon, + resizable, + fabMovable, + mobileBreakpoint, + fabAnchoredToViewport, + resizeIndicatorsVisible, + defaultFabPosition, + defaultFabMargin, + minWidth, + minHeight, + width, + height, + maxWidth, + maxHeight); + this.initializeHeader(); + this.initializeFooter(); + this.initializeContent(markdownEnabled != null && markdownEnabled); + this.initializeChatWindow(); } - private void setUI() { - getStyle() - .setZIndex(1000); - - overlay.getStyle() - .setMinHeight(DEFAULT_CONTENT_MIN_HEIGHT + "px") - .setMinWidth(DEFAULT_CONTENT_MIN_WIDTH + "px"); - - fabIcon = new SvgIcon("/icons/chatbot.svg"); - fabIcon.setSize(DEFAULT_FAB_ICON_SIZE + "px"); - - fab.getStyle() - .setBorderRadius("50%") - .setMinHeight(DEFAULT_FAB_SIZE + "px") - .setMinWidth(DEFAULT_FAB_SIZE + "px") - .setHeight(DEFAULT_FAB_SIZE + "px") - .setWidth(DEFAULT_FAB_SIZE + "px") - .setMaxHeight(DEFAULT_FAB_SIZE + "px") - .setMaxWidth(DEFAULT_FAB_SIZE + "px"); - fab.setIcon(fabIcon); + /** + * Initializes the UI applying the given configuration. Any {@code null} or invalid value falls + * back to its corresponding default. + */ + private void setUI( + SvgIcon fabIcon, + Boolean resizable, + Boolean fabMovable, + Integer mobileBreakpoint, + Boolean fabAnchoredToViewport, + Boolean resizeIndicatorsVisible, + FabPosition fabPosition, + String fabMargin, + String minWidth, + String minHeight, + String width, + String height, + String maxWidth, + String maxHeight) { + String fontSize = BADGE_FONT_SIZE; + getStyle().setZIndex(1000); + + // The overlay fills the popover content part (which Vaadin clamps to the viewport); resizing + // writes the desired size onto the popover content, never onto this Div, so it can't overflow. + // The resize bounds live in custom properties read by the resize script (setting them here has + // no layout effect on the 100% Div, avoiding any overflow). + overlay + .getStyle() + .setDisplay(Display.FLEX) + .setAlignItems(AlignItems.STRETCH) + .set("flex", "1") + .setMaxHeight("100%") + .setBoxSizing(Style.BoxSizing.BORDER_BOX) + .set(CSS_MIN_WIDTH, DEFAULT_CONTENT_MIN_WIDTH + "px") + .set(CSS_MIN_HEIGHT, DEFAULT_CONTENT_MIN_HEIGHT + "px"); + + mobileChatWindow.setSizeFull(); + mobileChatWindow.setModal(false); + mobileChatWindow.setDraggable(false); + mobileChatWindow.setResizable(false); + mobileChatWindow.addClassName(DEFAULT_DIALOG_TAG); + + this.fabIcon = fabIcon != null ? fabIcon : createDefaultFabIcon(); + this.fabIconComponent = this.fabIcon; + + fab.getStyle().setBorderRadius("50%"); + fab.setIcon(this.fabIcon); fab.addClassName(DEFAULT_FAB_CLASS); fab.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - fabWrapper.getStyle() - .setHeight(DEFAULT_FAB_SIZE + "px") - .setWidth(DEFAULT_FAB_SIZE + "px") + fabWrapper + .getStyle() .setDisplay(Style.Display.INLINE_FLEX) .setAlignItems(Style.AlignItems.CENTER) .setJustifyContent(Style.JustifyContent.CENTER) .setPosition(Style.Position.FIXED); + // Apply the FAB diameter and icon size (the single sizing code path). + setFabSize(DEFAULT_FAB_SIZE); + unreadBadge.setText(String.valueOf(unreadMessages)); unreadBadge.addClassName(DEFAULT_UNREAD_BADGE_CLASS); - String fontSize = "var(--lumo-font-size-xs)"; - unreadBadge.getStyle() + unreadBadge + .getStyle() .setTextAlign(Style.TextAlign.CENTER) .setPosition(Style.Position.ABSOLUTE) .setJustifyContent(Style.JustifyContent.CENTER) .setAlignItems(Style.AlignItems.CENTER) .setDisplay(Style.Display.FLEX) - .setPadding("var(--lumo-space-xs)") + .setPadding(BADGE_PADDING) .setFontWeight(Style.FontWeight.BOLD) .setFontSize(fontSize) .setBorderRadius("50%") - .setBackgroundColor("var(--lumo-warning-color)") + .setBackgroundColor(DEFAULT_UNREAD_BADGE_BACKGROUND) .setScale("0") .setMinHeight(fontSize) .setMinWidth(fontSize) @@ -201,197 +391,759 @@ private void setUI() { .setMaxWidth(fontSize) .setTop("0") .setRight("0") - .setColor("var(--lumo-warning-contrast-color)"); + .setColor(DEFAULT_UNREAD_BADGE_COLOR); chatWindow.add(overlay); chatWindow.setPosition(PopoverPosition.TOP); chatWindow.addClassName(DEFAULT_POPOVER_TAG); - chatWindow.setOpenOnClick(false); chatWindow.setTarget(fab); applyGenericResizerStyle(resizerTop, "top"); - resizerTop.getStyle() + resizerTop + .getStyle() .setTop("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth("100%"); applyGenericResizerStyle(resizerBottom, "bottom"); - resizerBottom.getStyle() + resizerBottom + .getStyle() .setBottom("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth("100%"); applyGenericResizerStyle(resizerTopRight, "top-right"); - resizerTopRight.getStyle() + resizerTopRight + .getStyle() .setRight("0") .setTop("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerBottomRight, "bottom-right"); - resizerBottomRight.getStyle() + resizerBottomRight + .getStyle() .setBottom("0") .setRight("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerRight, "right"); - resizerRight.getStyle() + resizerRight + .getStyle() .setTop("0") .setRight("0") .setHeight("100%") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerLeft, "left"); - resizerLeft.getStyle() + resizerLeft + .getStyle() .setTop("0") .setLeft("0") .setHeight("100%") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerBottomLeft, "bottom-left"); - resizerBottomLeft.getStyle() + resizerBottomLeft + .getStyle() .setBottom("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); applyGenericResizerStyle(resizerTopLeft, "top-left"); - resizerTopLeft.getStyle() + resizerTopLeft + .getStyle() .setTop("0") .setLeft("0") .setHeight(DEFAULT_RESIZER_SIZE + "px") .setWidth(DEFAULT_RESIZER_SIZE + "px"); overlay.add( - resizerTop, resizerBottom, - resizerRight, resizerTopRight, resizerBottomRight, - resizerLeft, resizerTopLeft, resizerBottomLeft, - container - ); - add(chatWindow, fabWrapper); + resizerTop, + resizerBottom, + resizerRight, + resizerTopRight, + resizerBottomRight, + resizerLeft, + resizerTopLeft, + resizerBottomLeft, + container); + + this.fabPosition = fabPosition != null ? fabPosition : DEFAULT_POSITION; + this.fabMargin = parseFabMargin(fabMargin); + + if (minWidth != null) { + setWindowMinWidth(minWidth); + } + if (minHeight != null) { + setWindowMinHeight(minHeight); + } + if (maxWidth != null) { + setWindowMaxWidth(maxWidth); + } + if (maxHeight != null) { + setWindowMaxHeight(maxHeight); + } + if (width != null) { + setWindowWidth(width); + } + if (height != null) { + setWindowHeight(height); + } + + // Auto-switching is opt-in: it is enabled only when the user explicitly defines a breakpoint in + // the constructor (or later via setMobileModeSwitchingEnabled). This avoids a breaking change + // and + // ensures mobile mode is only used when the user has prepared for it (e.g. a dialog close + // button). + if (mobileBreakpoint != null) { + this.mobileBreakpoint = Math.max(mobileBreakpoint, 0); + this.mobileModeSwitchingEnabled = true; + } + + setFabMovable(fabMovable != null ? fabMovable : DEFAULT_FAB_MOVABLE); + setWindowResizable(resizable != null ? resizable : DEFAULT_WINDOW_RESIZABLE); + setFabAnchoredToViewport( + fabAnchoredToViewport != null ? fabAnchoredToViewport : DEFAULT_FAB_ANCHORED_TO_VIEWPORT); + setResizeIndicatorsVisible( + resizeIndicatorsVisible != null + ? resizeIndicatorsVisible + : DEFAULT_RESIZE_INDICATORS_VISIBLE); + + add(chatWindow, fabWrapper, mobileChatWindow); + } + + /** + * Parses the given margin value, falling back to {@value #DEFAULT_FAB_MARGIN} when null or + * invalid. + */ + // Package-private (not private) so unit tests in this package can exercise the parsing fallbacks. + int parseFabMargin(String fabMargin) { + if (fabMargin == null) { + return DEFAULT_FAB_MARGIN; + } + try { + return Integer.parseInt(fabMargin.trim().replace("px", "")); + } catch (NumberFormatException e) { + return DEFAULT_FAB_MARGIN; + } + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + // Only one ChatAssistant is supported per UI; a second instance would register competing FAB + // and + // window surfaces. Fail fast, but allow this same instance to re-attach (detach/attach cycles). + UI ui = attachEvent.getUI(); + Object registered = ComponentUtil.getData(ui, UI_INSTANCE_KEY); + if (registered != null && registered != this) { + throw new IllegalStateException( + "Only one ChatAssistant is supported per UI, but another instance is already attached."); + } + ComponentUtil.setData(ui, UI_INSTANCE_KEY, this); + addComponentRefreshedListener( + "fc-chat-assistant-drag-listener", + "window.fcChatAssistantMovement($0, $1, $2, $3, $4, $5);", + this.getElement(), + fabWrapper.getElement(), + fab.getElement(), + fabMargin, + DEFAULT_DRAG_SENSITIVITY, + fabPosition.name()); + + if (mobileModeSwitchingEnabled) { + addComponentRefreshedListener( + "fc-chat-assistant-mobile-listener", + "window.fcChatAssistantMobileMode($0, $1);", + this.getElement(), + mobileBreakpoint); + } + // (Re)establish any screen-size observers registered before attach (and after a reattach). They + // are also re-applied when the popover opens, since the overlay's content is rebuilt each time. + screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + // Release the per-UI slot so the UI can be reused or a replacement instance attached. Only + // clear it + // when it still points at this instance, to avoid clobbering another instance's registration. + UI ui = detachEvent.getUI(); + if (ComponentUtil.getData(ui, UI_INSTANCE_KEY) == this) { + ComponentUtil.setData(ui, UI_INSTANCE_KEY, null); + } + super.onDetach(detachEvent); + } + + /** Receives mobile mode changes from the client when the viewport crosses the breakpoint. */ + @ClientCallable + protected void onMobileModeChange(boolean mobile) { + if (mobileModeSwitchingEnabled) { + setMode(mobile ? ChatAssistantMode.MOBILE : ChatAssistantMode.DESKTOP, true); + } + } + + /** + * Receives chat-window size threshold crossings from the client and dispatches to the matching + * listener. + */ + @ClientCallable + protected void onScreenSizeChange(int key, boolean matches) { + ScreenSizeListenerEntry entry = screenSizeListeners.get(key); + if (entry == null) { + // The listener was removed between client setup and this callback; ignore. + return; + } + entry.listener.onComponentEvent( + new ScreenSizeEvent(this, true, entry.width, entry.height, matches)); } - /** Receives click events from the client side to toggle the chat window's opened state. */ + /** Toggles the chat window's opened state. Called from the client on FAB click. */ @ClientCallable protected void onClick() { - if(isOpened()) { + if (isOpened()) { close(); - } - else { + } else { open(); } } /** Applies common styles to the resizer elements based on the specified direction. */ protected void applyGenericResizerStyle(Div resizer, String direction) { - resizer.getStyle() + resizer + .getStyle() .setPosition(Style.Position.ABSOLUTE) .setDisplay(Style.Display.INLINE_BLOCK) .setZIndex(1001); - resizer.addClassName(DEFAULT_RESIZE_CLASS + "-" + direction); + setResizerClass(resizer, direction, DEFAULT_WINDOW_RESIZABLE); + // A subtle arrowhead pointing in this resizer's drag direction. Hidden until the feature is + // enabled + // (setResizeIndicatorsVisible) and only shown while the resizer is actually draggable (the + // resize + // script toggles a class for that); see fc-chat-assistant-style.css. + Div arrow = new Div(); + arrow.addClassName(DEFAULT_RESIZE_CLASS + "-arrow"); + arrow.addClassName(DEFAULT_RESIZE_CLASS + "-arrow-" + direction); + resizer.add(arrow); } + protected void setResizerClass(Div resizer, String direction, boolean resizable) { + String classname = DEFAULT_RESIZE_CLASS + "-" + direction; + if (resizable && !resizer.getClassNames().contains(classname)) { + resizer.addClassName(classname); + } else if (!resizable && resizer.getClassNames().contains(classname)) { + resizer.removeClassName(classname); + } + } /** - * Adds a component refresh listener that prevents stacking up duplicate listeners on the client side. - * Uses a unique flag to track if the listener has already been added for this component instance, - * ensuring the callback only executes once per component refresh cycle. + * Runs the given JavaScript once per component instance, using a flag on the element to avoid + * registering duplicate client-side listeners across refreshes. * - * @param uniqueFlag a unique identifier for the component instance - * @param executable the JavaScript action to execute when the component is refreshed, - * @param parameters parameters for the executable + * @param uniqueFlag a unique identifier for this registration + * @param executable the JavaScript to execute + * @param parameters parameters for the executable */ - protected void addComponentRefreshedListener(String uniqueFlag, String executable, Serializable... parameters) { - this.getElement().executeJs( - String.format( - """ + protected void addComponentRefreshedListener( + String uniqueFlag, String executable, Serializable... parameters) { + this.getElement() + .executeJs( + String.format( + """ if(!this['%1$s']) { %2$s } if(!this['%1$s']) { this['%1$s'] = '%1$s'; }; - """, uniqueFlag, executable), - parameters - ); + """, + uniqueFlag, executable), + parameters); } - /** Sets the icon for the floating action button. - *
The icon's size is automatically adjusted to fit within the FAB. */ + /** + * Creates the default chatbot icon. The SVG is read from the classpath and inlined as a data URI, + * so it does not depend on a statically served path: it works both in the demo and when the + * add-on is packaged as a jar, and (unlike a StreamResource) needs no UI/session, keeping the + * icon serializable. + */ + protected static SvgIcon createDefaultFabIcon() { + return new SvgIcon(DEFAULT_FAB_ICON_SRC); + } + + /** Loads the bundled chatbot SVG from the classpath and encodes it as a data URI. */ + private static String loadDefaultFabIconSrc() { + try (InputStream in = + ChatAssistant.class.getResourceAsStream("/META-INF/resources/icons/chatbot.svg")) { + byte[] svg = in.readAllBytes(); + return "data:image/svg+xml;base64," + Base64.getEncoder().encodeToString(svg); + } catch (IOException | NullPointerException e) { + throw new IllegalStateException("Could not load the default chatbot icon", e); + } + } + + /** + * Sets the icon for the floating action button. The icon's size is automatically adjusted to fit + * within the current FAB size. + * + * @param icon the icon component, it cannot be null + */ public void setFabIcon(Component icon) { Objects.requireNonNull(icon, "Icon cannot be null"); - icon.getStyle() - .setWidth(DEFAULT_FAB_ICON_SIZE + "px") - .setHeight(DEFAULT_FAB_ICON_SIZE + "px"); + this.fabIcon = icon instanceof SvgIcon ? (SvgIcon) icon : null; + this.fabIconComponent = icon; + applyIconSize(icon, getFabIconSize()); fab.setIcon(icon); } - /** Sets the icon for the floating action button. Allows customizing the icon's size. */ + /** + * Sets the icon for the floating action button with a custom size. The size is capped at the + * current FAB size. + * + * @param icon the icon component, it cannot be null + * @param size the icon size in pixels, it must be greater than 0 + */ public void setFabIcon(Component icon, int size) { Objects.requireNonNull(icon, "Icon cannot be null"); if (size <= 0) { throw new IllegalArgumentException("Size must be greater than 0"); } - icon.getStyle() - .setWidth(Math.min(size, DEFAULT_FAB_SIZE) + "px") - .setHeight(Math.min(size, DEFAULT_FAB_SIZE) + "px"); + this.fabIcon = icon instanceof SvgIcon ? (SvgIcon) icon : null; + this.fabIconComponent = icon; + applyIconSize(icon, Math.min(size, fabSize)); fab.setIcon(icon); } + /** + * Sets the FAB diameter in pixels, scaling the icon to match. This is the single sizing entry + * point; the theme-variant API uses it to apply the {@link FabVariant#SMALL}/{@link + * FabVariant#LARGE} sizes. + * + * @param size the FAB diameter in pixels, it must be greater than 0 + */ + protected void setFabSize(int size) { + if (size <= 0) { + throw new IllegalArgumentException("Size must be greater than 0"); + } + this.fabSize = size; + fab.getStyle() + .setMinHeight(size + "px") + .setMinWidth(size + "px") + .setHeight(size + "px") + .setWidth(size + "px") + .setMaxHeight(size + "px") + .setMaxWidth(size + "px"); + fabWrapper.getStyle().setHeight(size + "px").setWidth(size + "px"); + if (fabIconComponent != null) { + applyIconSize(fabIconComponent, getFabIconSize()); + } + } + + /** The icon size that fits the current FAB size. */ + private int getFabIconSize() { + return Math.max(0, fabSize - 20); + } + + /** + * Pins a deterministic pixel size on the FAB icon, regardless of its concrete component type. + * Both width/height and min/max are set so the size is exact: the default chatbot SVG declares an + * intrinsic size and a {@code vaadin-icon} carries a Lumo {@code em}-based size, either of which + * would otherwise leak through and make the rendered size depend on prior state. + */ + private void applyIconSize(Component icon, int px) { + icon.getStyle() + .setWidth(px + "px") + .setHeight(px + "px") + .setMinWidth(px + "px") + .setMinHeight(px + "px") + .setMaxWidth(px + "px") + .setMaxHeight(px + "px"); + } + + /** + * Adds the given theme variants to the FAB. Color variants are applied to the underlying button + * and accumulate; the size variants {@link FabVariant#SMALL} and {@link FabVariant#LARGE} instead + * resize the FAB (and its icon) to a predefined diameter and are mutually exclusive, so if both + * are added the last one wins. + * + * @param variants the variants to add + * @since 5.1.0 + */ + public void addFabThemeVariants(FabVariant... variants) { + for (FabVariant variant : variants) { + if (variant.isSizeVariant()) { + activeSizeVariant = variant; + setFabSize(variant == FabVariant.LARGE ? DEFAULT_FAB_LARGE_SIZE : DEFAULT_FAB_SMALL_SIZE); + } else { + fab.addThemeVariants(variant.toButtonVariant()); + // Aura styles the accent colors via a CSS class rather than the theme attribute, so also add + // it when present; this makes the color render under both Lumo and Aura. + if (variant.getAuraClass() != null) { + fab.getElement().getClassList().add(variant.getAuraClass()); + } + } + } + } + + /** + * Removes the given theme variants from the FAB. Removing the currently active size variant + * ({@link FabVariant#SMALL}/{@link FabVariant#LARGE}) resets the FAB to its default size. + * + * @param variants the variants to remove + * @since 5.1.0 + */ + public void removeFabThemeVariants(FabVariant... variants) { + for (FabVariant variant : variants) { + if (variant.isSizeVariant()) { + if (variant == activeSizeVariant) { + activeSizeVariant = null; + setFabSize(DEFAULT_FAB_SIZE); + } + } else { + fab.removeThemeVariants(variant.toButtonVariant()); + if (variant.getAuraClass() != null) { + fab.getElement().getClassList().remove(variant.getAuraClass()); + } + } + } + } + /** Sets the opened state of the chat window. If true, opens the window; if false, closes it. */ public void setOpened(boolean opened) { - if(opened) { + if (opened) { open(); - } - else { + } else { close(); } } /** Opens the chat window. */ public void open() { - chatWindow.open(); + if (isMobile()) { + mobileChatWindow.open(); + } else { + chatWindow.open(); + } } /** Closes the chat window. */ public void close() { - chatWindow.close(); + if (isMobile()) { + mobileChatWindow.close(); + } else { + chatWindow.close(); + } } /** Returns true if the chat window is opened, false otherwise. */ public boolean isOpened() { - return chatWindow.isOpened(); + return isMobile() ? mobileChatWindow.isOpened() : chatWindow.isOpened(); + } + + /** Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. */ + protected boolean isMobile() { + return this.mode == ChatAssistantMode.MOBILE; + } + + /** Sets whether the chat window is resizable. */ + public void setWindowResizable(boolean resizable) { + this.resizable = resizable; + if (resizable) { + overlay.getElement().setAttribute("resizable", true); + } else { + overlay.getElement().removeAttribute("resizable"); + } + setResizerClass(resizerTop, "top", resizable); + setResizerClass(resizerBottom, "bottom", resizable); + setResizerClass(resizerLeft, "left", resizable); + setResizerClass(resizerRight, "right", resizable); + setResizerClass(resizerTopLeft, "top-left", resizable); + setResizerClass(resizerTopRight, "top-right", resizable); + setResizerClass(resizerBottomLeft, "bottom-left", resizable); + setResizerClass(resizerBottomRight, "bottom-right", resizable); + } + + /** Returns true if the chat window is resizable, false otherwise. * */ + public boolean isWindowResizable() { + return resizable; + } + + /** + * Sets whether a small arrowhead is shown on each resize handle, pointing in that handle's resize + * direction, to hint where the chat window can be dragged. The indicators are subtle (a {@code + * --lumo-contrast-20pct} triangle), hidden by default, and only shown on the handles that can + * currently be dragged given the window's position. + * + * @param visible whether the resize direction indicators are visible + * @since 5.1.0 + */ + public void setResizeIndicatorsVisible(boolean visible) { + this.resizeIndicatorsVisible = visible; + overlay.getElement().getClassList().set(RESIZE_INDICATOR_VISIBLE_CLASS, visible); + } + + /** + * Returns true if the resize direction indicators are visible, false otherwise. + * + * @since 5.1.0 + */ + public boolean isResizeIndicatorsVisible() { + return resizeIndicatorsVisible; + } + + /** + * Sets whether the FAB is movable. In {@link ChatAssistantMode#DESKTOP} mode this also becomes + * the preference restored when returning from {@link ChatAssistantMode#MOBILE} mode (which always + * forces the FAB non-movable). + * + * @param movable whether the FAB can be dragged + * @since 5.1.0 + */ + public void setFabMovable(boolean movable) { + this.fabMovable = movable; + // Remember the user's choice as the desktop preference, but not the forced value applied while + // in + // mobile mode. + if (!isMobile()) { + this.desktopFabMovablePreference = movable; + } + if (movable) { + fab.getElement().setAttribute("movable", true); + } else { + fab.getElement().removeAttribute("movable"); + } + } + + /** + * Returns whether the FAB is currently movable. This reflects the effective state: it is always + * {@code false} while in {@link ChatAssistantMode#MOBILE} mode, regardless of the desktop + * preference. + * + * @return {@code true} if the FAB is currently movable + * @since 5.1.0 + */ + public boolean isFabMovable() { + return fabMovable; + } + + /** + * Sets whether the FAB is anchored to the viewport. When {@code true} (the default) the FAB + * floats over the viewport; when {@code false} it is positioned within its container, so it can + * be placed inside a bounded element. A FAB that is not anchored to the viewport is not movable. + * + * @since 5.1.0 + */ + public void setFabAnchoredToViewport(boolean anchoredToViewport) { + this.fabAnchoredToViewport = anchoredToViewport; + fabWrapper.getStyle().setPosition(anchoredToViewport ? Position.FIXED : Position.ABSOLUTE); + if (anchoredToViewport) { + fab.getElement().setAttribute("anchored", true); + } else { + fab.getElement().removeAttribute("anchored"); + } + } + + /** + * Returns true if the FAB is anchored to the viewport, false if positioned within its container. + * + * @since 5.1.0 + */ + public boolean isFabAnchoredToViewport() { + return fabAnchoredToViewport; + } + + /** + * Moves the FAB to the given corner. This also becomes the position the FAB returns to when + * {@link #resetFabPosition()} is called. + * + * @param fabPosition the corner to move the FAB to, it cannot be null + * @since 5.1.0 + */ + public void setFabPosition(FabPosition fabPosition) { + Objects.requireNonNull(fabPosition, "Position cannot be null"); + this.fabPosition = fabPosition; + resetFabPosition(); + } + + /** + * Returns the FAB's configured corner. + * + * @since 5.1.0 + */ + public FabPosition getFabPosition() { + return fabPosition; + } + + /** + * Moves the FAB back to its configured corner. + * + * @since 5.1.0 + */ + public void resetFabPosition() { + this.getElement() + .executeJs( + "window.fcChatAssistantResetPosition($0, $1, $2);", + fabWrapper.getElement(), + fabMargin, + fabPosition.name()); } - /** Sets the chat window minimum width. Applies when resizing. **/ + /** + * Sets the chat window minimum width, the lower bound enforced while resizing. + * + * @param minWidth the minimum width as a CSS length (e.g. "150px") + * @since 5.1.0 + */ public void setWindowMinWidth(String minWidth) { - this.overlay.setMinWidth(minWidth); + this.minWidth = minWidth; + this.overlay.getStyle().set(CSS_MIN_WIDTH, minWidth); + applyWindowConstraints(); + } + + /** + * Sets the chat window minimum width, the lower bound enforced while resizing. + * + * @param minWidth the minimum width in px (e.g. 150) + * @since 5.1.0 + */ + public void setWindowMinWidth(int minWidth) { + this.minWidth = String.valueOf(minWidth) + "px"; + this.overlay.getStyle().set(CSS_MIN_WIDTH, this.minWidth); + applyWindowConstraints(); } - /** Sets the chat window minimum height. Applies when resizing. **/ + /** + * Sets the chat window minimum height, the lower bound enforced while resizing. + * + * @param minHeight the minimum height in px (e.g. 150) + * @since 5.1.0 + */ + public void setWindowMinHeight(int minHeight) { + this.minHeight = String.valueOf(minHeight) + "px"; + this.overlay.getStyle().set(CSS_MIN_HEIGHT, this.minHeight); + applyWindowConstraints(); + } + + /** + * Sets the chat window minimum height, the lower bound enforced while resizing. + * + * @param minHeight the minimum height as a CSS length (e.g. "150px") + * @since 5.1.0 + */ public void setWindowMinHeight(String minHeight) { - this.overlay.setMinHeight(minHeight); + this.minHeight = minHeight; + this.overlay.getStyle().set(CSS_MIN_HEIGHT, minHeight); + applyWindowConstraints(); } - /** Sets the chat window maximum width. Applies when resizing. **/ + /** + * Sets the chat window maximum width, the upper bound enforced while resizing. + * + * @param maxWidth the maximum width as a CSS length + * @since 5.1.0 + */ public void setWindowMaxWidth(String maxWidth) { - this.overlay.setMaxWidth(maxWidth); + this.overlay.getStyle().set(CSS_MAX_WIDTH, maxWidth); + applyWindowConstraints(); + } + + /** + * Sets the chat window maximum width, the upper bound enforced while resizing. + * + * @param maxWidth the maximum width in px (e.g. 150) + * @since 5.1.0 + */ + public void setWindowMaxWidth(int maxWidth) { + this.overlay.getStyle().set(CSS_MAX_WIDTH, String.valueOf(maxWidth) + "px"); + applyWindowConstraints(); } - /** Sets the chat window maximum height. Applies when resizing. **/ + /** + * Sets the chat window maximum height, the upper bound enforced while resizing. + * + * @param maxHeight the maximum height as a CSS length + * @since 5.1.0 + */ public void setWindowMaxHeight(String maxHeight) { - this.overlay.setMaxHeight(maxHeight); + this.overlay.getStyle().set(CSS_MAX_HEIGHT, maxHeight); + applyWindowConstraints(); + } + + /** + * Sets the chat window maximum height, the upper bound enforced while resizing. + * + * @param maxHeight the maximum height in px (e.g. 150) + * @since 5.1.0 + */ + public void setWindowMaxHeight(int maxHeight) { + this.overlay.getStyle().set(CSS_MAX_HEIGHT, String.valueOf(maxHeight) + "px"); + applyWindowConstraints(); } - /** Sets the chat window default height. Applies when resizing. **/ + /** + * Sets the chat window's initial height. Use absolute units (e.g. "400px"). + * + * @param height the height as an absolute CSS length + * @since 5.1.0 + */ public void setWindowHeight(String height) { - this.overlay.setHeight(height); + applyWindowSize("height", height); } - /** Sets the chat window default width. Applies when resizing. **/ + /** + * Sets the chat window's initial height. Use absolute units (e.g. 400). + * + * @param height the height in px (e.g. 400) + * @since 5.1.0 + */ + public void setWindowHeight(int height) { + applyWindowSize("height", String.valueOf(height) + "px"); + } + + /** + * Sets the chat window's initial width. Use absolute units (e.g. "400px"). + * + * @param width the width as an absolute CSS length + * @since 5.1.0 + */ public void setWindowWidth(String width) { - this.overlay.setWidth(width); + applyWindowSize("width", width); + } + + /** + * Sets the chat window's initial width. Use absolute units (e.g. 400). + * + * @param width the width in px (e.g. 400) + * @since 5.1.0 + */ + public void setWindowWidth(int width) { + applyWindowSize("width", String.valueOf(width) + "px"); + } + + /** Sizes the popover content part (works on Vaadin 24 and 25). */ + private void applyWindowSize(String dimension, String value) { + if (value != null) { + this.getElement() + .executeJs( + "window.fcChatAssistantSetWindowSize($0, $1, $2, $3);", + overlay, + DEFAULT_POPOVER_TAG, + dimension, + value); + } + } + + /** + * Re-applies the configured size and min/max bounds to the popover content part. Safe to call + * while the window is closed (it no-ops until the content part exists, then applies on the next + * open). + */ + private void applyWindowConstraints() { + this.getElement() + .executeJs("window.fcChatAssistantApplyConstraints($0, $1);", overlay, DEFAULT_POPOVER_TAG); } protected void initializeHeader() { @@ -407,99 +1159,159 @@ protected void initializeHeader() { @SuppressWarnings("unchecked") protected void initializeFooter() { this.messageInput = new MessageInput(); - this.messageInput.setWidthFull(); - this.messageInput.setMaxHeight("80px"); - this.messageInput.getStyle().set("padding", "0"); - this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener((se) -> this.sendMessage( - (T) Message.builder().messageTime( - LocalDateTime.now()).name("User").content(se.getValue()).build())); + this.messageInput + .getStyle() + .setMaxHeight("80px") + .set("width", "100%") + .setPadding("0 2px"); // Account for border when focused (it will get cropped otherwise) + + this.defaultSubmitListenerRegistration = + this.messageInput.addSubmitListener( + (se) -> + this.sendMessage( + (T) + Message.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .build())); this.whoIsTyping = new Span(); this.whoIsTyping.setClassName("chat-assistant-who-is-typing"); this.whoIsTyping.setVisible(false); + VerticalLayout footer = new VerticalLayout(this.whoIsTyping, this.messageInput); footer.setWidthFull(); footer.setSpacing(false); footer.setMargin(false); footer.setPadding(false); + this.footerContainer = footer; } @SuppressWarnings("unchecked") protected void initializeContent(boolean markdownEnabled) { - this.content.setRenderer(new ComponentRenderer<>( - message -> new ChatMessage<>(message, markdownEnabled), - (component, message) -> { - ((ChatMessage) component).setMessage(message); - return component; - }) - ); + this.content.setRenderer( + new ComponentRenderer<>( + message -> new ChatMessage<>(message, markdownEnabled), + (component, message) -> { + ((ChatMessage) component).setMessage(message); + return component; + })); this.content.setItems(this.messages); - this.content.setSizeFull(); + // Allow the content to shrink below its intrinsic size so the popover clamp produces an + // internal + // scroll instead of overflowing (the standard flexbox min-content fix). + this.content.getStyle().set("flex", "1").setMinHeight("0"); this.container.add(this.headerComponent, this.content, this.footerContainer); this.container.setPadding(true); this.container.setMargin(false); this.container.setSpacing(false); this.container.setSizeFull(); - this.container.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); - this.container.setFlexGrow(1.0, this.content); + this.container + .getStyle() + .set("flex", "1") + .setHeight(null) + // Allow the column to shrink below its min-content height so the configured/min window + // height wins + // and the message list scrolls internally instead of forcing the window taller. + .set("min-height", "0") + .setAlignItems(AlignItems.STRETCH) + .setAlignSelf(AlignSelf.STRETCH); } protected void initializeChatWindow() { this.chatWindow.setOpenOnClick(false); this.chatWindow.setCloseOnOutsideClick(false); - this.chatWindow.addOpenedChangeListener(ev -> { - if (ev.isOpened()) { - addComponentRefreshedListener( - "fc-chat-assistant-resize-top-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top');", - resizerTop.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-bottom-right-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom-right');", - resizerBottomRight.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-top-right-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top-right');", - resizerTopRight.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-right-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'right');", - resizerRight.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-bottom-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom');", - resizerBottom.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-left-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'left');", - resizerLeft.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-top-left-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'top-left');", - resizerTopLeft.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - addComponentRefreshedListener( - "fc-chat-assistant-resize-bottom-left-listener", - "window.fcChatAssistantResize($0, $1, $2, $3, $4, 'bottom-left');", - resizerBottomLeft.getElement(), overlay, - DEFAULT_POPOVER_TAG, DEFAULT_RESIZER_SIZE, DEFAULT_MAX_RESIZER_SIZE - ); - } - }); + this.chatWindow.addOpenedChangeListener( + ev -> { + if (ev.isOpened()) { + // The overlay (and its content part) is recreated on each open, so re-apply the last + // size + // (the configured initial size or whatever the user resized to), stored on the overlay + // Div. + this.getElement() + .executeJs( + "window.fcChatAssistantRestoreWindowSize($0, $1);", + overlay, + DEFAULT_POPOVER_TAG); + // Re-establish chat-window size observers against the freshly laid-out overlay and + // re-deliver + // the current state for this open. + screenSizeListeners.keySet().forEach(this::applyScreenSizeListener); + addComponentRefreshedListener( + "fc-chat-assistant-resize-top-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'top');", + this.getElement(), + resizerTop.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-bottom-right-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'bottom-right');", + this.getElement(), + resizerBottomRight.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-top-right-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'top-right');", + this.getElement(), + resizerTopRight.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-right-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'right');", + this.getElement(), + resizerRight.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-bottom-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'bottom');", + this.getElement(), + resizerBottom.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-left-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'left');", + this.getElement(), + resizerLeft.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-top-left-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'top-left');", + this.getElement(), + resizerTopLeft.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + addComponentRefreshedListener( + "fc-chat-assistant-resize-bottom-left-listener", + "window.fcChatAssistantResize($0, $1, $2, $3, $4, $5, 'bottom-left');", + this.getElement(), + resizerBottomLeft.getElement(), + overlay, + DEFAULT_POPOVER_TAG, + DEFAULT_RESIZER_SIZE, + DEFAULT_MAX_RESIZER_SIZE); + } + }); } public void setDataProvider(DataProvider dataProvider) { @@ -507,7 +1319,8 @@ public void setDataProvider(DataProvider dataProvider) { } /** - * Uses the provided string as the text shown over the message input to indicate that someone is typing. + * Uses the provided string as the text shown over the message input to indicate that someone is + * typing. * * @param whoIsTyping string to be shown as an indication of someone typing */ @@ -525,29 +1338,28 @@ public String getWhoIsTyping() { return whoIsTyping.getText(); } - /** - * Clears the text shown over the message input to indicate that someone is typing. - */ + /** Clears the text shown over the message input to indicate that someone is typing. */ public void clearWhoIsTyping() { this.whoIsTyping.setText(null); this.whoIsTyping.setVisible(false); } /** - * Sets the SubmitListener that will be notified when the user submits a message on the underlying messageInput. + * Sets the SubmitListener that will be notified when the user submits a message on the underlying + * messageInput. * * @param listener the listener that will be notified when the SubmitEvent is fired * @return registration for removal of the listener */ public Registration setSubmitListener(ComponentEventListener listener) { - if(this.defaultSubmitListenerRegistration != null) { + if (this.defaultSubmitListenerRegistration != null) { this.defaultSubmitListenerRegistration.remove(); } this.defaultSubmitListenerRegistration = this.messageInput.addSubmitListener(listener); return this.defaultSubmitListenerRegistration; } - private void refreshContent() { + public void refreshContent() { this.content.getDataProvider().refreshAll(); this.content.scrollToEnd(); } @@ -624,7 +1436,7 @@ public Component getHeaderComponent() { * @param component to be used as a replacement for the footer, it cannot be null */ public void setFooterComponent(Component component) { - Objects.requireNonNull(component, "Component cannot not be null"); + Objects.requireNonNull(component, "Component cannot be null"); this.container.remove(this.footerContainer); this.footerContainer = component; this.container.add(this.footerContainer); @@ -640,29 +1452,23 @@ public Component getFooterComponent() { } /** - * Scrolls to the given position. Scrolls so that the element is shown at - * the start of the visible area whenever possible. - *

- * If the index parameter exceeds current item set size the grid will scroll - * to the end. + * Scrolls to the given position. Scrolls so that the element is shown at the start of the visible + * area whenever possible. + * + *

If the index parameter exceeds current item set size the grid will scroll to the end. * - * @param position - * zero based index of the item to scroll to in the current view. + * @param position zero based index of the item to scroll to in the current view. */ public void scrollToIndex(int position) { this.content.scrollToIndex(position); } - /** - * Scrolls to the first element. - */ + /** Scrolls to the first element. */ public void scrollToStart() { this.content.scrollToStart(); } - /** - * Scrolls to the last element of the list. - */ + /** Scrolls to the last element of the list. */ public void scrollToEnd() { this.content.scrollToEnd(); } @@ -673,7 +1479,7 @@ public void scrollToEnd() { * @param renderer the renderer to use for rendering {@link Message} objects, it cannot be null */ public void setMessagesRenderer(Renderer renderer) { - Objects.requireNonNull(renderer, "Renderer cannot not be null"); + Objects.requireNonNull(renderer, "Renderer cannot be null"); this.content.setRenderer(renderer); } @@ -693,26 +1499,369 @@ public void setAvatarProvider(SerializableSupplier avatarProvider) { setFabIcon(avatar); } - /** - * Return the number of unread messages displayed in the chat assistant. - * @return the number of unread messages - */ + /** + * Returns the number of unread messages displayed in the chat assistant. + * + * @return the number of unread messages + * @since 5.1.0 + */ public int getUnreadMessages() { return Math.max(unreadMessages, 0); } /** - * Sets the number of unread messages to be displayed in the chat assistant. + * Sets the number of unread messages shown on the FAB badge. The value is clamped to the + * 0–99 range; the badge is hidden when it is 0. + * * @param unreadMessages the number of unread messages to set + * @since 5.1.0 */ public void setUnreadMessages(int unreadMessages) { this.unreadMessages = unreadMessages >= 0 ? Math.min(unreadMessages, 99) : 0; unreadBadge.setText(String.valueOf(this.unreadMessages)); - if(this.unreadMessages > 0) { + if (this.unreadMessages > 0) { unreadBadge.getStyle().setScale("1"); - } - else { + } else { unreadBadge.getStyle().setScale("0"); } } + + /** + * Sets the background and text color of the unread badge. If null or empty, the default values + * are used. + * + * @param background the background color of the unread badge + * @param color the text color of the unread badge + * @since 5.1.0 + */ + public void setUnreadBadgeColors(String background, String color) { + if (background != null && !background.isBlank()) { + unreadBadge.getStyle().set("background-color", background); + } else { + unreadBadge.getStyle().set("background-color", DEFAULT_UNREAD_BADGE_BACKGROUND); + } + if (color != null && !color.isEmpty()) { + unreadBadge.getStyle().set("color", color); + } else { + unreadBadge.getStyle().set("color", DEFAULT_UNREAD_BADGE_COLOR); + } + } + + /** + * Sets the display mode programmatically. In {@link ChatAssistantMode#MOBILE} mode the chat + * window opens as a full-screen dialog and the FAB is not movable (dragging would compete with + * touch scrolling, and the full-screen dialog already covers the viewport); the desktop movable + * preference is preserved and restored when switching back. In {@link ChatAssistantMode#DESKTOP} + * mode the window opens as an anchored popover. + * + *

When automatic switching is enabled (see {@link #setMobileModeSwitchingEnabled(boolean)}), + * this value may be overridden the next time the viewport crosses the configured breakpoint. To + * keep full manual control, disable automatic switching first. + * + * @param mode the mode to switch to, it cannot be null + * @since 5.1.0 + */ + public void setMode(ChatAssistantMode mode) { + Objects.requireNonNull(mode, "Mode cannot be null"); + setMode(mode, false); + } + + /** + * Returns the current display mode. + * + * @since 5.1.0 + */ + public ChatAssistantMode getMode() { + return mode; + } + + /** + * Applies the given mode, reconciling the active surface and open state, and fires a {@link + * ModeChangedEvent} when the mode actually changes. + * + * @param mode the mode to switch to + * @param fromClient whether the change originated from a client-side breakpoint crossing + */ + protected void setMode(ChatAssistantMode mode, boolean fromClient) { + if (this.mode == mode) { + return; + } + boolean mobile = mode == ChatAssistantMode.MOBILE; + // Capture the open state before switching so it can be carried over to the other surface. + boolean wasOpened = isOpened(); + this.mode = mode; + this.container.setPadding(!mobile); + if (mobile) { + if (overlay.getChildren().anyMatch(child -> child == container)) { + overlay.remove(container); + mobileChatWindow.add(container); + } + // Mobile mode always disables dragging. The desktop preference is retained in + // desktopFabMovablePreference (setFabMovable does not overwrite it while in mobile mode) and + // restored when leaving mobile, so isFabMovable() honestly reports false here. + setFabMovable(false); + // The popover would otherwise stay open with no content; move the open state to the dialog. + if (wasOpened) { + chatWindow.close(); + mobileChatWindow.open(); + } + } else { + if (mobileChatWindow.getChildren().anyMatch(child -> child == container)) { + mobileChatWindow.remove(container); + overlay.add(container); + } + setFabMovable(this.desktopFabMovablePreference); + // Move the open state from the dialog back to the popover. + if (wasOpened) { + mobileChatWindow.close(); + chatWindow.open(); + } + } + // Return the FAB to its configured corner; its dragged position is not meaningful across modes. + resetFabPosition(); + fireEvent(new ModeChangedEvent(this, fromClient, this.mode)); + } + + /** + * Sets the mobile mode programmatically. Convenience wrapper over {@link + * #setMode(ChatAssistantMode)}. + * + * @param mobileMode {@code true} for {@link ChatAssistantMode#MOBILE}, {@code false} for {@link + * ChatAssistantMode#DESKTOP} + * @since 5.1.0 + */ + public void setMobileMode(boolean mobileMode) { + setMode(mobileMode ? ChatAssistantMode.MOBILE : ChatAssistantMode.DESKTOP); + } + + /** + * Returns true if the component is currently in {@link ChatAssistantMode#MOBILE} mode. + * + * @since 5.1.0 + */ + public boolean isMobileMode() { + return isMobile(); + } + + /** + * Adds a listener that is notified whenever the component switches between {@link + * ChatAssistantMode#MOBILE} and {@link ChatAssistantMode#DESKTOP} mode. + * + * @param listener the listener to add; it receives the mode the component switched to + * @return a registration for removing the listener + * @since 5.1.0 + */ + public Registration addModeChangedListener(ComponentEventListener listener) { + return addListener(ModeChangedEvent.class, listener); + } + + /** + * Event fired when the chat assistant switches between mobile and desktop mode. + * + * @since 5.1.0 + */ + public static class ModeChangedEvent extends ComponentEvent> { + + private final ChatAssistantMode mode; + + protected ModeChangedEvent( + ChatAssistant source, boolean fromClient, ChatAssistantMode mode) { + super(source, fromClient); + this.mode = mode; + } + + /** Returns the mode the component switched to. */ + public ChatAssistantMode getMode() { + return mode; + } + } + + /** + * Adds a listener that is notified when the chat window's own size crosses the given threshold. + * At least one of {@code width}/{@code height} must be non-null; a {@code null} axis is not + * tracked. When both are given, the listener fires only when both are simultaneously satisfied + * (AND). + * + *

The chat window only has a size while it is open, so the listener observes size changes + * (drag resize, {@link #setWindowWidth}/{@link #setWindowHeight}, or viewport clamping) while + * open. On each open it is invoked once with the current state, then only when the size crosses + * the threshold. The threshold is inclusive: a window exactly at the threshold counts as above it + * ({@link ScreenSizeEvent#isAboveThreshold()} is {@code true}). Each listener only receives + * events for its own threshold. + * + * @param width the width threshold in pixels, or {@code null} to ignore width + * @param height the height threshold in pixels, or {@code null} to ignore height + * @param listener the listener to add + * @return a registration for removing the listener + * @since 5.1.0 + */ + public Registration addScreenSizeListener( + Integer width, Integer height, ComponentEventListener listener) { + Objects.requireNonNull(listener, "Listener cannot be null"); + if (width == null && height == null) { + throw new IllegalArgumentException("At least one of width or height must be provided"); + } + if ((width != null && width <= 0) || (height != null && height <= 0)) { + throw new IllegalArgumentException("Thresholds must be greater than 0"); + } + + int key = ++screenSizeKeySeq; + screenSizeListeners.put(key, new ScreenSizeListenerEntry(width, height, listener)); + if (this.getUI().isPresent()) { + applyScreenSizeListener(key); + } + + return () -> { + screenSizeListeners.remove(key); + this.getElement() + .executeJs("window.fcChatAssistantScreenSizeOff?.($0, $1);", this.getElement(), key); + }; + } + + /** + * (Re)registers a single chat-window size observer on the client. Called on attach and on each + * popover open (the overlay content is rebuilt each open); the JS replaces any existing observer + * for the key and re-delivers the current state, so it is safe to call repeatedly. + */ + private void applyScreenSizeListener(Integer key) { + ScreenSizeListenerEntry entry = screenSizeListeners.get(key); + if (entry == null) { + return; + } + this.getElement() + .executeJs( + "window.fcChatAssistantScreenSize($0, $1, $2, $3, $4);", + this.getElement(), + overlay, + key, + entry.width, + entry.height); + } + + /** Per-key bookkeeping for a screen-size listener: its thresholds and the listener to invoke. */ + private static final class ScreenSizeListenerEntry implements Serializable { + private final Integer width; + private final Integer height; + private final ComponentEventListener listener; + + ScreenSizeListenerEntry( + Integer width, Integer height, ComponentEventListener listener) { + this.width = width; + this.height = height; + this.listener = listener; + } + } + + /** + * Direction in which the chat window crossed a configured threshold. + * + * @since 5.1.0 + */ + public enum ScreenSizeDirection { + /** The chat window grew to or past the threshold (it is now at or above it). */ + INCREASED, + /** The chat window shrank below the threshold (it is now under it). */ + DECREASED + } + + /** + * Event fired when the chat window's size crosses a width and/or height threshold registered + * through {@link #addScreenSizeListener(Integer, Integer, ComponentEventListener)}. It reports + * the crossing direction and the configured threshold(s); no live size is exposed. + * + * @since 5.1.0 + */ + public static class ScreenSizeEvent extends ComponentEvent> { + + private final Integer widthThreshold; + private final Integer heightThreshold; + private final boolean aboveThreshold; + + protected ScreenSizeEvent( + ChatAssistant source, + boolean fromClient, + Integer widthThreshold, + Integer heightThreshold, + boolean aboveThreshold) { + super(source, fromClient); + this.widthThreshold = widthThreshold; + this.heightThreshold = heightThreshold; + this.aboveThreshold = aboveThreshold; + } + + /** The configured width threshold in pixels, or {@code null} if width was not tracked. */ + public Integer getWidthThreshold() { + return widthThreshold; + } + + /** The configured height threshold in pixels, or {@code null} if height was not tracked. */ + public Integer getHeightThreshold() { + return heightThreshold; + } + + /** + * Returns {@code true} when the chat window is now at or above the threshold (it increased past + * it), {@code false} when it is now below (it decreased under it). + */ + public boolean isAboveThreshold() { + return aboveThreshold; + } + + /** Convenience view of {@link #isAboveThreshold()} as a direction. */ + public ScreenSizeDirection getDirection() { + return aboveThreshold ? ScreenSizeDirection.INCREASED : ScreenSizeDirection.DECREASED; + } + } + + /** + * Returns the maximum screen width, in pixels, below which mobile mode is activated + * automatically. + * + * @return the breakpoint in pixels + * @since 5.1.0 + */ + public int getMobileBreakpoint() { + return mobileBreakpoint; + } + + /** + * Enables or disables automatic switching between mobile and desktop mode based on the configured + * breakpoint. Automatic switching is disabled by default; it is enabled either by defining + * a breakpoint in the constructor or by calling this method with {@code true}. Enable it only + * after preparing the mobile experience (e.g. providing a way to close the full-screen dialog). + * + *

When disabled, the component is left in whatever mode it is currently in (freeze), and the + * mode can only be changed manually via {@link #setMode(ChatAssistantMode)}. When enabled, the + * breakpoint is evaluated against the current viewport width. If no breakpoint was configured, + * the default ({@value #DEFAULT_MOBILE_BREAKPOINT}px) is used. + * + * @param enabled {@code true} to switch automatically on viewport changes, {@code false} to + * freeze + * @since 5.1.0 + */ + public void setMobileModeSwitchingEnabled(boolean enabled) { + if (this.mobileModeSwitchingEnabled == enabled) { + return; + } + this.mobileModeSwitchingEnabled = enabled; + if (enabled) { + this.getElement() + .executeJs( + "window.fcChatAssistantMobileMode($0, $1);", + this.getElement(), + this.mobileBreakpoint); + } else { + this.getElement().executeJs("window.fcChatAssistantMobileModeOff($0);", this.getElement()); + } + } + + /** + * Returns whether automatic switching between mobile and desktop mode is enabled. + * + * @return {@code true} if automatic switching is enabled + * @since 5.1.0 + */ + public boolean isMobileModeSwitchingEnabled() { + return mobileModeSwitchingEnabled; + } } diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index 1fd1b7e..b0a1984 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,13 +20,13 @@ package com.flowingcode.vaadin.addons.chatassistant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; -import com.flowingcode.vaadin.addons.markdown.MarkdownViewer; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasComponents; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.markdown.Markdown; import java.time.format.DateTimeFormatter; import lombok.EqualsAndHashCode; @@ -35,101 +35,108 @@ * * @author mmlopez */ -@SuppressWarnings("serial") @JsModule("@vaadin/message-list/src/vaadin-message.js") @Tag("vaadin-message") @CssImport("./styles/fc-chat-message-styles.css") -@EqualsAndHashCode(callSuper=false) +@EqualsAndHashCode(callSuper = false) public class ChatMessage extends Component implements HasComponents { - + private T message; private boolean markdownEnabled; private Div loader; - private MarkdownViewer markdownViewer; - + private Markdown markdown; + private static final String DEFAULT_MARKDOWN_CLASS = "fc-chat-message-markdown"; + /** * Creates a new ChatMessage based on the supplied message without markdown support. - * + * * @param message message used to populate the ChatMessage instance */ public ChatMessage(T message) { this(message, false); } - + /** * Creates a new ChatMessage based on the supplied message. - * + * * @param message message used to populate the ChatMessage instance * @param markdownEnabled whether the message supports markdown or not */ public ChatMessage(T message, boolean markdownEnabled) { this.markdownEnabled = markdownEnabled; - loader = new Div(new Div(),new Div(), new Div(), new Div()); + loader = new Div(new Div(), new Div(), new Div(), new Div()); loader.setClassName("lds-ellipsis"); loader.setVisible(false); this.add(loader); if (markdownEnabled) { - markdownViewer = new MarkdownViewer(message.getContent()); - this.add(markdownViewer); + markdown = new Markdown(message.getContent()); + markdown.setClassName(DEFAULT_MARKDOWN_CLASS); + this.add(markdown); } setMessage(message); } /** * Updates the component by setting the current underlying message. - * + * * @param message message used to populate the ChatMessage instance */ public void setMessage(T message) { this.message = message; updateMessage(message); - if (message.getName()!=null) { + if (message.getName() != null) { this.setUserName(message.getName()); - if (message.getAvatar()!=null) { + if (message.getAvatar() != null) { this.setUserImg(message.getAvatar()); } } - if (message.getMessageTime()!=null) { - String formattedTime = message.getMessageTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + if (message.getMessageTime() != null) { + String formattedTime = + message.getMessageTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); this.setTime(formattedTime); } } /** * Updates the displayed message content and loading state. + * * @param message */ private void updateMessage(T message) { loader.setVisible(message.isLoading()); if (!message.isLoading()) { if (markdownEnabled) { - markdownViewer.setContent(message.getContent()); + markdown.setContent(message.getContent()); } else { - this.getElement().executeJs("[...this.childNodes].forEach(node => node.nodeType === 3 && this.removeChild(node));"); - this.getElement().executeJs("this.appendChild(document.createTextNode($0));", message.getContent()); + // Strip any stale text node left in the slot, then append the current content as fresh + // text. + this.getElement() + .executeJs( + "[...this.childNodes].forEach(node => node.nodeType === 3 && this.removeChild(node));" + + "this.appendChild(document.createTextNode($0));", + message.getContent()); } } } - + /** * Returns the underlying message. - * + * * @return the message object used to populate this ChatMessage */ public T getMessage() { return message; } - + private void setUserName(String username) { getElement().setAttribute("user-name", username); } - + private void setUserImg(String imageUrl) { getElement().setAttribute("user-img", imageUrl); } - + private void setTime(String time) { getElement().setAttribute("time", time); } - } diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java new file mode 100644 index 0000000..a047001 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/ChatAssistantMode.java @@ -0,0 +1,31 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant.model; + +/** + * The display mode of the chat assistant: {@link #MOBILE} opens the chat window as a full-screen + * dialog, while {@link #DESKTOP} opens it as an anchored popover. + * + * @since 5.1.0 + */ +public enum ChatAssistantMode { + MOBILE, + DESKTOP; +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java new file mode 100644 index 0000000..523aff8 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabPosition.java @@ -0,0 +1,32 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant.model; + +/** + * The corner of the viewport where the floating action button is initially placed. + * + * @since 5.1.0 + */ +public enum FabPosition { + BOTTOM_RIGHT, + BOTTOM_LEFT, + TOP_RIGHT, + TOP_LEFT; +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java new file mode 100644 index 0000000..37b769a --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/FabVariant.java @@ -0,0 +1,97 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant.model; + +import com.vaadin.flow.component.button.ButtonVariant; + +/** + * Theme variants supported by the chat assistant floating action button (FAB). This is a curated + * subset of the underlying {@link ButtonVariant}s, so the add-on controls exactly which variants it + * exposes and stays insulated from {@code ButtonVariant} changes across Vaadin versions. + * + *

Variants fall into two groups: the size variants {@link #SMALL} and {@link #LARGE} + * resize the FAB (and its icon) to a predefined diameter and are mutually exclusive (only one is + * active at a time), while the remaining color variants are applied to the underlying + * button and accumulate. + * + *

Theme compatibility. The add-on is compatible with both the Lumo and Aura themes of + * Vaadin 25. {@link #PRIMARY} uses the {@code primary} token, which both themes style. Aura, + * however, styles the accent colors through CSS classes rather than the {@code theme} attribute, so + * {@link #SUCCESS} and {@link #ERROR} additionally carry an Aura class (see {@link #getAuraClass()}) + * to render correctly under Aura as well as Lumo. {@link #LUMO_CONTRAST} has no Aura equivalent and is + * effective only under Lumo. + * + * @since 5.1.0 + */ +public enum FabVariant { + + /** Renders the FAB at the small predefined diameter. Size variant. */ + SMALL(ButtonVariant.LUMO_SMALL, true, null), + /** Renders the FAB at the large predefined diameter. Size variant. */ + LARGE(ButtonVariant.LUMO_LARGE, true, null), + /** Applies the primary color to the FAB. Cross-theme (Lumo and Aura). */ + PRIMARY(ButtonVariant.LUMO_PRIMARY, false, null), + /** Applies the success (green) color to the FAB. Cross-theme (Lumo and Aura). */ + SUCCESS(ButtonVariant.LUMO_SUCCESS, false, "aura-accent-green"), + /** Applies the error (red) color to the FAB. Cross-theme (Lumo and Aura). */ + ERROR(ButtonVariant.LUMO_ERROR, false, "aura-accent-red"), + /** Applies the contrast color to the FAB. Effective only under the Lumo theme. */ + LUMO_CONTRAST(ButtonVariant.LUMO_CONTRAST, false, null); + + private final ButtonVariant buttonVariant; + private final boolean sizeVariant; + private final String auraClass; + + FabVariant(ButtonVariant buttonVariant, boolean sizeVariant, String auraClass) { + this.buttonVariant = buttonVariant; + this.sizeVariant = sizeVariant; + this.auraClass = auraClass; + } + + /** + * Returns the underlying {@link ButtonVariant} this FAB variant maps to. + * + * @return the mapped {@link ButtonVariant} + */ + public ButtonVariant toButtonVariant() { + return buttonVariant; + } + + /** + * Returns whether this is a size variant ({@link #SMALL}/{@link #LARGE}), which resizes the FAB + * rather than being delegated to the underlying button. + * + * @return {@code true} if this is a size variant + */ + public boolean isSizeVariant() { + return sizeVariant; + } + + /** + * Returns the Aura CSS class that must be applied alongside the {@link #toButtonVariant() Lumo + * theme variant} for this variant to render under the Aura theme, or {@code null} when no Aura + * class is needed (the variant is either cross-theme via its {@code theme} token, or Lumo-only). + * + * @return the Aura CSS class name, or {@code null} + */ + public String getAuraClass() { + return auraClass; + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java index 5ffdafc..75852ea 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/model/Message.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,20 +33,16 @@ * * @author mmlopez */ -@SuppressWarnings("serial") @Getter @Setter @SuperBuilder @EqualsAndHashCode(of = "id") public class Message implements Serializable { - @Builder.Default - private UUID id = UUID.randomUUID(); - @Builder.Default - private String content = ""; + @Builder.Default private UUID id = UUID.randomUUID(); + @Builder.Default private String content = ""; private boolean loading; private String name; private String avatar; private LocalDateTime messageTime; - } diff --git a/src/main/resources/META-INF/VAADIN/package.properties b/src/main/resources/META-INF/VAADIN/package.properties index c66616f..7225f8d 100644 --- a/src/main/resources/META-INF/VAADIN/package.properties +++ b/src/main/resources/META-INF/VAADIN/package.properties @@ -1 +1,20 @@ +### +# #%L +# Chat Assistant Add-on +# %% +# Copyright (C) 2023 - 2026 Flowing Code +# %% +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# #L% +### vaadin.allowed-packages=com.flowingcode diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js index 4c8cdf8..5bbee69 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-movement.js @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,7 +17,43 @@ * limitations under the License. * #L% */ -window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensitivityRaw) => { +// Resolves the FAB's rendered size, falling back to the offset/CSS size when the element has not +// been laid out yet (getBoundingClientRect returns 0 before the first layout pass). +function fcChatAssistantSize(fab) { + const rect = fab.getBoundingClientRect(); + const width = rect.width || fab.offsetWidth || parseFloat(fab.style.width) || 0; + const height = rect.height || fab.offsetHeight || parseFloat(fab.style.height) || 0; + return { width, height }; +} + +// Returns the box the FAB is positioned against: the viewport when fixed, otherwise its offset +// parent (e.g. a containing div). The right/bottom offsets are relative to this box. +function fcChatAssistantBounds(item) { + if (getComputedStyle(item).position === 'fixed' || !item.offsetParent) { + return { width: window.innerWidth, height: window.innerHeight }; + } + const rect = item.offsetParent.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; +} + +// Computes the FAB's position, expressed as right/bottom offsets, for the given corner. +function fcChatAssistantCornerPosition(item, fab, corner, margin) { + const size = fcChatAssistantSize(fab); + const bounds = fcChatAssistantBounds(item); + const right = margin; + const bottom = margin; + const left = Math.max(margin, bounds.width - size.width - margin); + const top = Math.max(margin, bounds.height - size.height - margin); + switch (corner) { + case 'BOTTOM_LEFT': return { x: left, y: bottom }; + case 'TOP_RIGHT': return { x: right, y: top }; + case 'TOP_LEFT': return { x: left, y: top }; + case 'BOTTOM_RIGHT': + default: return { x: right, y: bottom }; + } +} + +window.fcChatAssistantMovement = (root, item, fab, marginRaw, sensitivityRaw, positionRaw) => { // Prevent duplicate initialization const guard = `__fcChatAssistantMovement`; if (item[guard]) { @@ -30,6 +66,8 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti const snapTransition = 'all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)'; const position = { x: margin, y: margin }; const initialPosition = { x: margin, y: margin }; + // Expose the live position so the reset hook can move the FAB after initialization. + item.__fcPosition = position; let screenWidth = window.innerWidth; let screenHeight = window.innerHeight; @@ -40,38 +78,9 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti const resizeHandler = (_) => { screenWidth = window.innerWidth; screenHeight = window.innerHeight; - - // Adjust container dimensions to fit within screen bounds - if (container) { - const rect = container.getBoundingClientRect(); - let widthAdjustment = 0; - let heightAdjustment = 0; - if (rect.left < 0) { - widthAdjustment = Math.abs(rect.left); - } - if (rect.right > screenWidth) { - widthAdjustment = Math.max(widthAdjustment, rect.right - screenWidth); - } - if (rect.top < 0) { - heightAdjustment = Math.abs(rect.top); - } - if (rect.bottom > screenHeight) { - heightAdjustment = Math.max(heightAdjustment, rect.bottom - screenHeight); - } - // Apply adjustments - if (widthAdjustment > 0) { - const minWidth = parseFloat(container.style.minWidth) || 0; - const newWidth = Math.max(minWidth, rect.width - widthAdjustment); - container.style.width = newWidth + 'px'; - } - if (heightAdjustment > 0) { - const minHeight = parseFloat(container.style.minHeight) || 0; - const newHeight = Math.max(minHeight, rect.height - heightAdjustment); - container.style.height = newHeight + 'px'; - } - } - - // Reposition the item to ensure it stays within the new screen bounds + + // The popover content part clamps itself to the viewport (max-height/width: 100%), so no + // manual container shrinking is needed here. Just keep the FAB within the new screen bounds. snapToBoundary(); }; window.addEventListener("resize", resizeHandler); @@ -79,6 +88,8 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti const origDisconnectedCallback = root.disconnectedCallback?.bind(root); root.disconnectedCallback = () => { window.removeEventListener("resize", resizeHandler); + window.fcChatAssistantMobileModeOff?.(root); + window.fcChatAssistantScreenSizeOffAll?.(root); origDisconnectedCallback?.(); }; @@ -112,7 +123,8 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti } item.addEventListener('pointerdown', (e) => { - isDragging = true; + isDragging = fab.hasAttribute('movable') && fab.hasAttribute('anchored'); + if (!isDragging) return; fab.classList.add('dragging'); item.setPointerCapture(e.pointerId); item.style.transition = sizeTransition; @@ -123,13 +135,17 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti item.addEventListener('pointermove', (e) => { if (!isDragging) return; const itemRect = fab.getBoundingClientRect(); - // Calculate position from right and bottom edges + // Calculate position from right and bottom edges, keeping the FAB centered on the cursor. position.x = screenWidth - e.clientX - (itemRect.width / 2); position.y = screenHeight - e.clientY - (itemRect.height / 2); - + // Do not move if delta is below sensitivity + if (isClickOnlyEvent()) { + return; + } updatePosition(); }); + item.addEventListener('click', () => onFabClick()); item.addEventListener('pointerup', (e) => stopDragging(e)); item.addEventListener('pointerleave', (e) => stopDragging(e)); item.addEventListener('pointercancel', (e) => stopDragging(e)); @@ -149,5 +165,149 @@ window.fcChatAssistantMovement = (root, item, container, fab, marginRaw, sensiti } } + function onFabClick() { + if(!fab.hasAttribute('movable') || !fab.hasAttribute('anchored')) { + root.$server?.onClick(); + } + } + + // Apply the configured corner once the FAB has a real size. getBoundingClientRect returns 0 + // before the first layout pass (and while the FAB lives in a hidden tab), which would push it + // off-screen for any corner other than the bottom-right default. An IntersectionObserver fires + // when the FAB becomes visible, so the size is known by then. + function applyCorner() { + const start = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); + position.x = start.x; + position.y = start.y; + initialPosition.x = start.x; + initialPosition.y = start.y; + updatePosition(); + } + if (fcChatAssistantSize(fab).width > 0) { + applyCorner(); + } else { + const observer = new IntersectionObserver((_, obs) => { + if (fcChatAssistantSize(fab).width > 0) { + obs.disconnect(); + applyCorner(); + } + }); + observer.observe(fab); + } + updatePosition(); }; + +// Moves the FAB back to the given corner, animating the transition like a drag-snap. +window.fcChatAssistantResetPosition = (item, marginRaw, positionRaw) => { + const margin = parseFloat(marginRaw); + const fab = item.querySelector('.fc-chat-assistant-fab') || item; + const target = fcChatAssistantCornerPosition(item, fab, positionRaw, margin); + // A gentle ease-out with a small overshoot (1.1 vs the snappier 1.275 used while dragging) so the + // reset settles into the corner without bouncing. + const resetTransition = 'all 0.45s cubic-bezier(0.22, 0.61, 0.36, 1.1)'; + item.style.transition = resetTransition; + item.style.right = target.x + 'px'; + item.style.bottom = target.y + 'px'; + // Keep the live drag state in sync so the next drag starts from the reset position. + if (item.__fcPosition) { + item.__fcPosition.x = target.x; + item.__fcPosition.y = target.y; + } +}; + +// Removes any active media-query listener, freezing the component in its current mode. Also clears +// the server-side listener guard so a later re-enable re-attaches cleanly. +window.fcChatAssistantMobileModeOff = (root) => { + if (root.__fcMobileMql && root.__fcMobileHandler) { + root.__fcMobileMql.removeEventListener('change', root.__fcMobileHandler); + } + root.__fcMobileMql = null; + root.__fcMobileHandler = null; + root['fc-chat-assistant-mobile-listener'] = null; +}; + +// Watches the viewport width against the given breakpoint and notifies the server whenever the +// mobile/desktop state changes. Fires once immediately so the initial mode matches the viewport. +// A breakpoint of 0 (or less) disables mobile mode entirely (always desktop). +window.fcChatAssistantMobileMode = (root, breakpointRaw) => { + // Replace any previous listener so repeated calls (e.g. breakpoint changes) don't stack up. + window.fcChatAssistantMobileModeOff(root); + const breakpoint = parseFloat(breakpointRaw); + if (!(breakpoint > 0)) { + // Disabled: ensure desktop mode and register no listener. + root.$server?.onMobileModeChange(false); + return; + } + const mql = window.matchMedia('(max-width: ' + breakpoint + 'px)'); + const handler = (e) => root.$server?.onMobileModeChange(e.matches); + mql.addEventListener('change', handler); + root.__fcMobileMql = mql; + root.__fcMobileHandler = handler; + // Evaluate the current viewport so the initial mode is correct. + handler(mql); +}; + +// Watches the CHAT WINDOW's own size (the given overlay Div, which fills the popover content) against a +// width and/or height threshold, identified by `key`. A null threshold means that axis is not tracked; +// when both are given they must both be satisfied (AND). The server is notified only when the +// above/below state actually flips (crossing), plus once on registration with the initial state, so a +// resize within one side of the threshold sends no calls. Multiple keys coexist independently. +window.fcChatAssistantScreenSize = (root, overlayDiv, key, widthRaw, heightRaw) => { + if (!root.__fcScreenSizeListeners) { + root.__fcScreenSizeListeners = {}; + } + // Replace any previous registration under this key so repeated calls don't stack observers. + window.fcChatAssistantScreenSizeOff(root, key); + + const width = parseFloat(widthRaw); + const height = parseFloat(heightRaw); + const isAbove = (rect) => + (Number.isNaN(width) || rect.width >= width) + && (Number.isNaN(height) || rect.height >= height); + + const entry = { observer: null, last: null }; + const notify = (rect) => { + // Ignore the pre-layout 0x0 state so the initial delivery reflects the real size. + if (rect.width === 0 && rect.height === 0) { + return; + } + const above = isAbove(rect); + if (above !== entry.last) { + entry.last = above; + root.$server?.onScreenSizeChange(key, above); + } + }; + + entry.observer = new ResizeObserver((entries) => notify(entries[0].contentRect)); + entry.observer.observe(overlayDiv); + root.__fcScreenSizeListeners[key] = entry; + // Deliver the current state immediately (if already laid out; otherwise the observer's first + // callback delivers it). + notify(overlayDiv.getBoundingClientRect()); +}; + +// Removes the size observer registered under `key` (no-op if absent). +window.fcChatAssistantScreenSizeOff = (root, key) => { + const listeners = root.__fcScreenSizeListeners; + if (listeners && listeners[key]) { + listeners[key].observer?.disconnect(); + delete listeners[key]; + } + // Clear the refresh guard so the same key can be re-registered later. + root['fc-chat-assistant-screen-size-' + key] = null; +}; + +// Removes every screen-size observer (used on disconnect). +window.fcChatAssistantScreenSizeOffAll = (root) => { + const listeners = root.__fcScreenSizeListeners; + if (listeners) { + Object.keys(listeners).forEach((key) => { + listeners[key].observer?.disconnect(); + // Clear the per-key refresh guard too (mirroring fcChatAssistantScreenSizeOff), otherwise it + // would block re-registration of the same key after a detach/reattach. + root['fc-chat-assistant-screen-size-' + key] = null; + }); + } + root.__fcScreenSizeListeners = {}; +}; diff --git a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js index 4b9c750..3e5806d 100644 --- a/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js +++ b/src/main/resources/META-INF/resources/frontend/fc-chat-assistant-resize.js @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -18,15 +18,23 @@ * #L% */ -// Combined resize functionality for all directions -window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw, direction) => { - // Prevent duplicate initialization +// Combined resize functionality for all directions. +// `container` is the chat overlay Div (it fills the popover content part, so its rendered size is the +// current content size). Resizing writes the desired size onto the popover's public content-height/ +// content-width, which Vaadin clamps to the viewport, so the content can never overflow the popover. +window.fcChatAssistantResize = (root, item, container, popoverTag, sizeRaw, maxSizeRaw, direction) => { + // Prevent duplicate initialization. The handlers always attach once; whether a drag is allowed is + // decided live (see isResizable) so toggling resizable after init takes effect immediately. const guard = `__fcChatAssistantResize_${direction}`; if (item[guard]) { return; } item[guard] = true; + // The resizable state is read live at event time, not captured at init, so setWindowResizable() + // enables/disables resizing on an already-initialized handle. + const isResizable = () => container.hasAttribute('resizable'); + const size = parseFloat(sizeRaw); const maxSize = parseFloat(maxSizeRaw); const overlayTag = "vaadin-popover-overlay".toUpperCase(); @@ -35,9 +43,26 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw let minHeight = 0; let maxWidth = Infinity; let maxHeight = Infinity; - let overlay; + let overlay; // the vaadin-popover-overlay element (used for shouldDrag positioning rules) + let contentPart; // its [part='content'], which our overlay Div fills let isDragging = false; + // Write the desired size directly onto the overlay's content part. This part keeps + // overflow:auto + max-height/width:100% in both Vaadin 24 and 25, so the size is clamped to the + // viewport and the content never overflows. (Vaadin 25 removed the content-height/width API, so + // sizing the part directly is the version-agnostic approach.) + // The desired size is also stored on the (durable) overlay Div so it can be restored when the + // popover is closed and reopened, since Vaadin rebuilds the overlay's shadow DOM each time. + const sizeTarget = () => contentPart || overlay; + const setContentHeight = (px) => { + container.style.setProperty('--fc-height', px + 'px'); + sizeTarget().style.height = px + 'px'; + }; + const setContentWidth = (px) => { + container.style.setProperty('--fc-width', px + 'px'); + sizeTarget().style.width = px + 'px'; + }; + const directionConfig = { 'top': { shouldDrag: () => overlay?.style?.bottom && !overlay?.style?.top, @@ -45,7 +70,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = container.getBoundingClientRect().top - e.clientY; const newHeight = offsetY + container.clientHeight; if (newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } }, setupDrag: () => { @@ -67,12 +92,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = container.getBoundingClientRect().top - e.clientY; const newHeight = offsetY + container.clientHeight; if(newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = e.clientX - container.getBoundingClientRect().right; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -94,7 +119,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetX = e.clientX - container.getBoundingClientRect().right; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -116,12 +141,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = e.clientY - container.getBoundingClientRect().bottom; const newHeight = offsetY + container.clientHeight; if (newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = e.clientX - container.getBoundingClientRect().right; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -143,7 +168,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = e.clientY - container.getBoundingClientRect().bottom; const newHeight = offsetY + container.clientHeight; if (newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } }, setupDrag: () => { @@ -165,12 +190,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = e.clientY - container.getBoundingClientRect().bottom; const newHeight = offsetY + container.clientHeight; if(newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = container.getBoundingClientRect().left - e.clientX; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -192,7 +217,7 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetX = container.getBoundingClientRect().left - e.clientX; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -214,12 +239,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const offsetY = container.getBoundingClientRect().top - e.clientY; const newHeight = offsetY + container.clientHeight; if(newHeight >= minHeight && newHeight <= maxHeight) { - container.style.height = newHeight + 'px'; + setContentHeight(newHeight); } const offsetX = container.getBoundingClientRect().left - e.clientX; const newWidth = offsetX + container.clientWidth; if (newWidth >= minWidth && newWidth <= maxWidth) { - container.style.width = newWidth + 'px'; + setContentWidth(newWidth); } }, setupDrag: () => { @@ -239,31 +264,86 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const config = directionConfig[direction]; if (!config) { - console.error(`Invalid direction: ${direction}. Valid directions: ${Object.keys(directionConfig).join(', ')}`); + console.error(`Invalid direction: ${JSON.stringify(direction)}. Valid directions: ${Object.keys(directionConfig).join(', ')}`); return; } + // Reflects the live "can this handle be dragged right now" state as a class, so the CSS can show the + // direction arrowhead only on the handles that are currently draggable (see fc-chat-assistant-style.css). + let styleObserver = null; + function updateCanDrag() { + item.classList.toggle('fc-chat-assistant-resize-can-drag', isResizable() && config.shouldDrag()); + } + + // shouldDrag() is a pure function of the overlay's inline top/bottom/left/right, which the popover + // mutates on open and during cross-edge resizes. Also watch the container's `resizable` attribute so + // toggling setWindowResizable() shows/hides the direction indicators immediately (without a hover). + // Re-observe whenever a (new) overlay is resolved. + function observeOverlayStyle() { + styleObserver?.disconnect(); + styleObserver = new MutationObserver(updateCanDrag); + styleObserver.observe(overlay, { attributes: true, attributeFilter: ['style'] }); + styleObserver.observe(container, { attributes: true, attributeFilter: ['resizable'] }); + updateCanDrag(); + } + window.requestAnimationFrame(fetchOverlay); - setTimeout(fetchOverlay, 2000); // in case the overlay is not available immediately, check again after 2 seconds + // In case the overlay is not available immediately, check again after 2 seconds. Tracked so it can + // be cancelled on disconnect (it may otherwise fire against a torn-down component). + const fetchOverlayTimeout = setTimeout(fetchOverlay, 2000); - // Fetch the root overlay component + // Fetch the root overlay component and its content part. The popover rebuilds its overlay (and the + // content part) on each open, so re-resolve whenever the cached nodes are gone or detached — keeping + // stale (disconnected) references out of shouldDrag()/setContentWidth()/setContentHeight(). function fetchOverlay() { - if (!overlay) { + if (!overlay || !overlay.isConnected) { + contentPart = null; // a new overlay means the old content part is stale too overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag); if(!overlay) { overlay = [...document.getElementsByClassName(popoverTag)].find(p => p.tagName == overlayTag); } + if (overlay) { + observeOverlayStyle(); + } + } + if (overlay && (!contentPart || !contentPart.isConnected)) { + contentPart = overlay.shadowRoot?.querySelector('[part="content"]'); } } + const resizeHandler = () => updateCanDrag(); + window.addEventListener('resize', resizeHandler); + + // Mirror the movement module's cleanup: when the component is detached, drop this direction's window + // resize listener, disconnect its style observer, and cancel the pending overlay lookup. Without this + // these closures (capturing item/container/overlay) would leak on detach. The guard on the durable + // resizer Div prevents re-adding on reopen, so this only runs on a genuine disconnect. + const origDisconnectedCallback = root.disconnectedCallback?.bind(root); + root.disconnectedCallback = () => { + window.removeEventListener('resize', resizeHandler); + styleObserver?.disconnect(); + clearTimeout(fetchOverlayTimeout); + origDisconnectedCallback?.(); + }; + item.addEventListener('pointerenter', (e) => { - if (config.shouldDrag()) { + // Refresh the overlay/content-part references in case the popover was closed and reopened since + // the last interaction (which rebuilds the overlay's shadow DOM). + fetchOverlay(); + updateCanDrag(); + if (isResizable() && config.shouldDrag()) { item.classList.add('active'); + // Resize bounds come from custom properties on the overlay Div (set from the Java + // setWindowMin*/Max* methods), so they don't affect the 100% Div's own layout. const computedStyle = window.getComputedStyle(container); - minHeight = computedStyle.minHeight ? parseFloat(computedStyle.minHeight) || 0 : 0; - minWidth = computedStyle.minWidth ? parseFloat(computedStyle.minWidth) || 0 : 0; - maxWidth = computedStyle.maxWidth ? parseFloat(computedStyle.maxWidth) || Infinity : Infinity; - maxHeight = computedStyle.maxHeight ? parseFloat(computedStyle.maxHeight) || Infinity : Infinity; + const bound = (prop, dflt) => { + const value = parseFloat(computedStyle.getPropertyValue(prop)); + return Number.isFinite(value) ? value : dflt; + }; + minHeight = bound('--fc-min-height', 0); + minWidth = bound('--fc-min-width', 0); + maxHeight = bound('--fc-max-height', Infinity); + maxWidth = bound('--fc-max-width', Infinity); } else { item.classList.remove('active'); @@ -271,9 +351,12 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw }); item.addEventListener('pointerdown', (e) => { - isDragging = config.shouldDrag(); + isDragging = isResizable() && config.shouldDrag(); if (isDragging) { item.setPointerCapture(e.pointerId); + // The handle grows while dragging (setupDrag), which would push the arrowhead outside the + // overlay; hide it for the duration of the resize. + item.classList.add('fc-chat-assistant-resize-resizing'); config.setupDrag(); } }); @@ -291,11 +374,83 @@ window.fcChatAssistantResize = (item, container, popoverTag, sizeRaw, maxSizeRaw const wasDragging = isDragging; isDragging = false; item.classList.remove('active'); + item.classList.remove('fc-chat-assistant-resize-resizing'); if (wasDragging) { config.cleanupDrag(); if (item.hasPointerCapture(e.pointerId)) { item.releasePointerCapture(e.pointerId); } + // A resize can pin/unpin edges, changing which handles are draggable. + updateCanDrag(); } } }; + +// Resolves the popover overlay's [part='content'] element, retrying briefly because the overlay is +// (re)created lazily when the popover opens. +function fcChatAssistantContentPart(popoverTag, callback, attempts = 0) { + const overlayTag = "vaadin-popover-overlay".toUpperCase(); + const overlay = document.querySelector(`.${popoverTag}`)?.shadowRoot?.querySelector(overlayTag) + || [...document.getElementsByClassName(popoverTag)].find(p => p.tagName === overlayTag); + const contentPart = overlay?.shadowRoot?.querySelector('[part="content"]'); + if (contentPart) { + callback(contentPart); + } else if (attempts < 20) { + setTimeout(() => fcChatAssistantContentPart(popoverTag, callback, attempts + 1), 100); + } +} + +// Applies the chat window's configured size and bounds to the popover content part, sourced from the +// `--fc-*` custom properties on the durable overlay Div (which survive close/reopen; the content part is +// rebuilt each open). This is the single source of truth used on open and whenever a Java size/bound +// setter runs. The content part is sized directly (works on Vaadin 24 and 25), and the desired +// width/height are clamped into the [min, max] range so an explicit/previously-resized size that violates +// a (later-set) bound is corrected. Setting the part directly is required because a `var()` on +// ::part(content) cannot read a custom property set on the inner overlay Div (custom properties inherit +// downward, and the overlay Div is a descendant of the content part). +window.fcChatAssistantApplyConstraints = (overlayDiv, popoverTag) => { + const raw = (prop) => overlayDiv.style.getPropertyValue(prop); + const num = (prop, dflt) => { + const value = parseFloat(raw(prop)); + return Number.isFinite(value) ? value : dflt; + }; + const widthRaw = raw('--fc-width'); + const heightRaw = raw('--fc-height'); + const minWidth = raw('--fc-min-width'); + const minHeight = raw('--fc-min-height'); + const maxWidth = raw('--fc-max-width'); + const maxHeight = raw('--fc-max-height'); + + // Clamp a desired size string into [min, max] (min wins if they cross, matching CSS). + const clamp = (valueRaw, minProp, maxProp) => { + const value = parseFloat(valueRaw); + if (!Number.isFinite(value)) { + return valueRaw; + } + let result = Math.min(value, num(maxProp, Infinity)); + result = Math.max(result, num(minProp, 0)); + return result + 'px'; + }; + + fcChatAssistantContentPart(popoverTag, (contentPart) => { + if (minWidth) contentPart.style.minWidth = minWidth; + if (minHeight) contentPart.style.minHeight = minHeight; + if (maxWidth) contentPart.style.maxWidth = maxWidth; + if (maxHeight) contentPart.style.maxHeight = maxHeight; + if (widthRaw) contentPart.style.width = clamp(widthRaw, '--fc-min-width', '--fc-max-width'); + if (heightRaw) contentPart.style.height = clamp(heightRaw, '--fc-min-height', '--fc-max-height'); + }); +}; + +// Sets the chat window's initial/desired width or height. Stores it on the durable overlay Div (so it +// survives close/reopen) and (re)applies all constraints, clamping the new size to the current bounds. +window.fcChatAssistantSetWindowSize = (overlayDiv, popoverTag, dimension, value) => { + overlayDiv.style.setProperty(dimension === 'height' ? '--fc-height' : '--fc-width', value); + window.fcChatAssistantApplyConstraints(overlayDiv, popoverTag); +}; + +// Re-applies the desired size and bounds (stored on the overlay Div) to the content part. Called whenever +// the popover opens, because Vaadin rebuilds the overlay's shadow DOM and the inline size is lost. +window.fcChatAssistantRestoreWindowSize = (overlayDiv, popoverTag) => { + window.fcChatAssistantApplyConstraints(overlayDiv, popoverTag); +}; diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css index 8468fcc..e16025c 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-assistant-style.css @@ -7,9 +7,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,14 +22,15 @@ .fc-chat-assistant-fab { cursor: pointer; transform: scale(1); - box-shadow: var(--lumo-box-shadow-m); + /* Cross-theme fallback: Lumo token, then a literal shadow (Aura has no equivalent scale token). */ + box-shadow: var(--lumo-box-shadow-m, 0 2px 6px rgba(0, 0, 0, 0.2)); } /* Expansion state when being dragged */ .fc-chat-assistant-fab.dragging { cursor: grabbing; transform: scale(1.15); - box-shadow: var(--lumo-box-shadow-xl); + box-shadow: var(--lumo-box-shadow-xl, 0 8px 24px rgba(0, 0, 0, 0.3)); } .fc-chat-assistant-popover { @@ -38,7 +39,56 @@ } .fc-chat-assistant-unread-badge { - transition: all 0.15s ease-out; + transition: all 0.12s ease-out; +} + +vaadin-popover-overlay::part(overlay) { + /* Cross-theme fallback: Lumo radius token, then Aura base radius, then a literal. */ + border-radius: var(--lumo-border-radius-l, var(--aura-base-radius, 12px)); +} + +/* The chat overlay fills this part; the part already clamps itself to the viewport + (max-height/width: 100%), so the content shrinks with the popover instead of overflowing. + The min/max bounds are applied as inline styles from JS (fcChatAssistantApplyConstraints), sourced from + the component's configured values, so they are intentionally not hardcoded here. */ +vaadin-popover-overlay.fc-chat-assistant-popover::part(content) { + display: flex; + flex-direction: column; + max-width : 100%; + max-height: 100%; +} + +vaadin-popover.fc-chat-assistant-popover::part(content) { + display: flex; + flex-direction: column; + max-width : 100%; + max-height: 100%; +} + +/* Let the chat overlay Div shrink so its VirtualList scrolls internally under the clamp. */ +vaadin-popover-overlay.fc-chat-assistant-popover::part(content) > * { + min-height: 0; + min-width: 0; +} + +/* Let the chat overlay Div shrink so its VirtualList scrolls internally under the clamp. */ +vaadin-popover.fc-chat-assistant-popover::part(content) > * { + min-height: 0; + min-width: 0; +} + +vaadin-dialog.fc-chat-assistant-dialog::part(content) { + height : 100%; + width : 100%; + display : flex; + align-items: stretch; +} + +vaadin-dialog-overlay.fc-chat-assistant-dialog::part(content) { + height : 100%; + width : 100%; + display : flex; + align-items: stretch; } /* Specific cursors for each corner */ @@ -52,3 +102,42 @@ .fc-chat-assistant-resize-top.active { cursor: n-resize; } .fc-chat-assistant-resize-left.active { cursor: w-resize; } .fc-chat-assistant-resize-right.active { cursor: e-resize; } + +/* Resize direction indicators: a subtle arrowhead pointing in each handle's drag direction. Hidden by + default; shown via setResizeIndicatorsVisible, and only on handles that can currently be dragged. */ +.fc-chat-assistant-resize-arrow { + position: absolute; + width: 0; + height: 0; + pointer-events: none; /* never steal drags from the resizer */ + display: none; + /* filled triangle pointing right by default; rotated per direction below */ + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid var(--lumo-contrast-20pct, var(--aura-border-color, rgba(0, 0, 0, 0.2))); +} + +/* Feature enabled (overlay class) AND this resizer is draggable now (can-drag on the resizer). */ +.fc-chat-assistant-resize-indicator-visible + .fc-chat-assistant-resize-can-drag + .fc-chat-assistant-resize-arrow { + display: block; +} + +/* While actively resizing, the handle grows and would push the arrowhead outside the overlay; hide it. + Specificity matches the show rule above and comes later, so it wins. */ +.fc-chat-assistant-resize-indicator-visible + .fc-chat-assistant-resize-resizing.fc-chat-assistant-resize-can-drag + .fc-chat-assistant-resize-arrow { + display: none; +} + +/* Anchor each arrow inside its resizer and rotate it to point outward (base triangle points right). */ +.fc-chat-assistant-resize-arrow-right { right: 3px; top: 50%; transform: translateY(-50%); } +.fc-chat-assistant-resize-arrow-left { left: 3px; top: 50%; transform: translateY(-50%) rotate(180deg); } +.fc-chat-assistant-resize-arrow-top { top: 3px; left: 50%; transform: translateX(-50%) rotate(-90deg); } +.fc-chat-assistant-resize-arrow-bottom { bottom: 3px; left: 50%; transform: translateX(-50%) rotate(90deg); } +.fc-chat-assistant-resize-arrow-top-right { top: 3px; right: 3px; transform: rotate(-45deg); } +.fc-chat-assistant-resize-arrow-bottom-right { bottom: 3px; right: 3px; transform: rotate(45deg); } +.fc-chat-assistant-resize-arrow-bottom-left { bottom: 3px; left: 3px; transform: rotate(135deg); } +.fc-chat-assistant-resize-arrow-top-left { top: 3px; left: 3px; transform: rotate(-135deg); } diff --git a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css index 03712b9..5fa4ad1 100644 --- a/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css +++ b/src/main/resources/META-INF/resources/frontend/styles/fc-chat-message-styles.css @@ -88,17 +88,14 @@ color: var(--lumo-secondary-text-color, var(--vaadin-text-color-secondary)); } -.wmde-markdown { - height: 100%; - margin-top: 0px; - margin-bottom: 0px; - padding-top: 0px; - padding-bottom: 0px; - display: flex; - flex-direction: column; - background: none !important; +/* Markdown messages render block HTML into the vaadin-markdown light DOM, whose default block margins + add whitespace inside and between message bubbles. Collapse the outer margins and tighten the gaps. */ +vaadin-markdown.fc-chat-message-markdown > :first-child { + margin-top: 0; } - -.language-mermaid { - padding: 0px !important; +vaadin-markdown.fc-chat-message-markdown > :last-child { + margin-bottom: 0; +} +vaadin-markdown.fc-chat-message-markdown > * + * { + margin-top: var(--lumo-space-s, 0.5rem); } diff --git a/src/main/resources/META-INF/resources/icons/chatbot.svg b/src/main/resources/META-INF/resources/icons/chatbot.svg index 5acc55a..159f2ab 100644 --- a/src/main/resources/META-INF/resources/icons/chatbot.svg +++ b/src/main/resources/META-INF/resources/icons/chatbot.svg @@ -1,4 +1,24 @@ + + chatAssistant = + ChatAssistant.builder().fabIcon(icon).fabAnchoredToViewport(false).build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // Seed the conversation with a greeting. + chatAssistant.sendMessage( + Message.builder() + .content("Use the buttons to move me around the box.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + + // Move the FAB to each corner of the box. + HorizontalLayout controls = + new HorizontalLayout( + new Button("Top left", ev -> chatAssistant.setFabPosition(FabPosition.TOP_LEFT)), + new Button("Top right", ev -> chatAssistant.setFabPosition(FabPosition.TOP_RIGHT)), + new Button("Bottom left", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_LEFT)), + new Button( + "Bottom right", ev -> chatAssistant.setFabPosition(FabPosition.BOTTOM_RIGHT))); + controls.getStyle().set("flex-wrap", "wrap"); + + // A visible, relatively-positioned box that hosts the non-fixed FAB. + Span description = + new Span( + "The FAB is not anchored to the viewport but positioned relative to this box. This behaviour disables dragging."); + Div box = new Div(chatAssistant); + box.getStyle() + .set("position", "relative") + .set("border", "2px dashed var(--lumo-contrast-30pct)") + .set("border-radius", "var(--lumo-border-radius-l)") + .setWidth("600px") + .setHeight("400px"); + + add(controls, description, box); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java index 076ca59..695ab73 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,11 +23,9 @@ import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Image; -import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -35,77 +33,128 @@ import com.vaadin.flow.router.Route; import java.time.LocalDateTime; import java.util.Timer; +import java.util.TimerTask; @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Basic Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/basic-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantDemo extends VerticalLayout { - + public ChatAssistantDemo() { + // Create the assistant with all defaults ChatAssistant chatAssistant = new ChatAssistant<>(); - SvgIcon icon = new SvgIcon("chatbot.svg"); - icon.setColor("var(--lumo-primary-contrast-color)"); - chatAssistant.setFabIcon(icon); + // Set a sensible default window size chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Render each message with a custom component that also shows its tagline. + chatAssistant.setMessagesRenderer( + new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + })); + + // Echo messages submitted by the user from the chat input. + chatAssistant.setSubmitListener( + se -> + chatAssistant.sendMessage( + CustomMessage.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .tagline("Generated by user") + .build())); + + // Text area used to compose the assistant's answer. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); - message.addKeyPressListener(ev->{ - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); - }, - (component, m) -> { - ((CustomChatMessage) component).setMessage(m); - return component; - })); - chatAssistant.setSubmitListener(se -> { - chatAssistant.sendMessage(CustomMessage.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).tagline("Generated by user").build()); - }); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed text as an assistant message. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - CustomMessage m = CustomMessage.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); + chat.addClickListener( + ev -> { + chatAssistant.sendMessage( + CustomMessage.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); + message.clear(); + }); - chatAssistant.sendMessage(m); - message.clear(); - }); + // Send a message in a loading state, then resolve it after 5 seconds. Button chatWithThinking = new Button("Chat With Thinking"); - chatWithThinking.addClickListener(ev -> { - CustomMessage delayedMessage = CustomMessage.builder().loading(true).content(message.getValue()) - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); + chatWithThinking.addClickListener( + ev -> { + CustomMessage delayedMessage = + CustomMessage.builder() + .loading(true) + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); + chatAssistant.sendMessage(delayedMessage); + + UI currentUI = UI.getCurrent(); + new Timer() + .schedule( + new TimerTask() { + @Override + public void run() { + currentUI.access( + () -> { + delayedMessage.setLoading(false); + chatAssistant.updateMessage(delayedMessage); + }); + } + }, + 5000); + message.clear(); + }); - UI currentUI = UI.getCurrent(); - chatAssistant.sendMessage(delayedMessage); + Button unread = new Button("Toggle unread badge"); + unread.addClickListener( + ev -> { + if (chatAssistant.getUnreadMessages() > 0) { + chatAssistant.setUnreadMessages(0); + } else { + chatAssistant.setUnreadMessages((int) (Math.random() * 98) + 1); + } + }); - new Timer().schedule(new java.util.TimerTask() { - @Override - public void run() { - currentUI.access(() -> { - delayedMessage.setLoading(false); - chatAssistant.updateMessage(delayedMessage); - }); - } - }, 5000); + // Seed the conversation + chatAssistant.sendMessage( + CustomMessage.builder() + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); - message.clear(); - }); - chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); + HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking, unread); + row.setSpacing(true); + row.setPadding(false); + row.getStyle().set("flex-wrap", "wrap"); - chatAssistant.setUnreadMessages(4); + unread.click(); + chatAssistant.setOpened(true); - add(message, chat, chatWithThinking, chatAssistant); + add(message, row, chatAssistant); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java index c4d151b..e0fbe74 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemoView.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,14 +25,19 @@ import com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.Route; -@SuppressWarnings("serial") @ParentLayout(DemoLayout.class) @Route("chat-assistant") @GithubLink("https://github.com/FlowingCode/ChatAssistant") public class ChatAssistantDemoView extends TabbedDemo { public ChatAssistantDemoView() { + // Core usage and FAB/window configuration. addDemo(ChatAssistantDemo.class); + addDemo(ChatAssistantFabConfigDemo.class); + addDemo(ChatAssistantBoxDemo.class); + addDemo(ChatAssistantModeDemo.class); + + // Content and data-handling features. addDemo(ChatAssistantLazyLoadingDemo.class); addDemo(ChatAssistantMarkdownDemo.class); addDemo(ChatAssistantGenerativeDemo.class); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java new file mode 100644 index 0000000..80e78dd --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantFabConfigDemo.java @@ -0,0 +1,147 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("FAB Configuration Demo") +@Route(value = "chat-assistant/fab-config-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantFabConfigDemo extends VerticalLayout { + + public ChatAssistantFabConfigDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + + // Build the assistant with a custom FAB icon and initial window size via the builder. + ChatAssistant chatAssistant = + ChatAssistant.builder().fabIcon(icon).width("400px").height("400px").build(); + + // Size variants resize the FAB (and its icon): SMALL (50px) and LARGE (72px); removing the + // active + // one restores the default (60px). Only one size is active at a time. + Button small = new Button("Small", ev -> chatAssistant.addFabThemeVariants(FabVariant.SMALL)); + Button large = new Button("Large", ev -> chatAssistant.addFabThemeVariants(FabVariant.LARGE)); + Button defaultSize = + new Button( + "Default size", + ev -> chatAssistant.removeFabThemeVariants(FabVariant.SMALL, FabVariant.LARGE)); + + // Color variants accumulate, so replace the other colors before applying the chosen one; this + // keeps + // the FAB showing exactly the selected color. + Button success = + new Button( + "Success", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.ERROR, FabVariant.LUMO_CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.SUCCESS); + }); + Button error = + new Button( + "Error", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.LUMO_CONTRAST); + chatAssistant.addFabThemeVariants(FabVariant.ERROR); + }); + Button contrast = + new Button( + "Contrast", + ev -> { + chatAssistant.removeFabThemeVariants(FabVariant.SUCCESS, FabVariant.ERROR); + chatAssistant.addFabThemeVariants(FabVariant.LUMO_CONTRAST); + }); + Button clearColors = + new Button( + "Clear colors", + ev -> + chatAssistant.removeFabThemeVariants( + FabVariant.SUCCESS, FabVariant.ERROR, FabVariant.LUMO_CONTRAST)); + + // Lock down or free up the FAB and the window, and toggle the resize direction hints. Each + // toggle + // shows a notification reporting the resulting state. + Button movable = + new Button( + "Toggle movable", + ev -> { + chatAssistant.setFabMovable(!chatAssistant.isFabMovable()); + Notification.show("FAB movable: " + chatAssistant.isFabMovable()); + }); + Button resizable = + new Button( + "Toggle resizable", + ev -> { + chatAssistant.setWindowResizable(!chatAssistant.isWindowResizable()); + Notification.show("Window resizable: " + chatAssistant.isWindowResizable()); + }); + Button indicators = + new Button( + "Toggle resize hints", + ev -> { + chatAssistant.setResizeIndicatorsVisible(!chatAssistant.isResizeIndicatorsVisible()); + Notification.show( + "Resize hints visible: " + chatAssistant.isResizeIndicatorsVisible()); + }); + + // Seed the conversation and open the window so the styling and resize hints are visible right + // away. + chatAssistant.sendMessage( + Message.builder() + .content("Use the buttons to restyle the FAB.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + chatAssistant.setOpened(true); + + add( + section("Size variants", small, large, defaultSize), + section("Color variants", success, error, contrast, clearColors), + section("Behavior", movable, resizable, indicators), + chatAssistant); + } + + /** A titled group of buttons whose row wraps on narrow screens. */ + private static VerticalLayout section(String title, Button... buttons) { + Span heading = new Span(title); + + HorizontalLayout row = new HorizontalLayout(buttons); + row.getStyle().set("flex-wrap", "wrap"); + + VerticalLayout group = new VerticalLayout(heading, row); + group.setPadding(false); + group.setSpacing(false); + return group; + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java index f8afa9e..a174d46 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantGenerativeDemo.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2025 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,11 +23,10 @@ import com.flowingcode.vaadin.addons.demo.SourcePosition; import com.google.common.base.Strings; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Image; import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.data.renderer.ComponentRenderer; @@ -42,108 +41,141 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Generative Answer Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/generative-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantGenerativeDemo extends VerticalLayout { - + public ChatAssistantGenerativeDemo() { - String sampleText = "Hi, I'm an advanced language model. I'm here to help you demonstrate" - + " how a text-streaming chat component works in Vaadin. As you can see, each word appears" - + " with a slight pause, simulating the time it would take me to \"think\" and generate" - + " the next word. I hope this is useful for your demonstration!"; - + String sampleText = + "Hi, I'm an advanced language model. I'm here to help you demonstrate" + + " how a text-streaming chat component works in Vaadin. As you can see, each word appears" + + " with a slight pause, simulating the time it would take me to \"think\" and generate" + + " the next word. I hope this is useful for your demonstration!"; + + // Create the assistant with all defaults and give it a custom FAB icon. ChatAssistant chatAssistant = new ChatAssistant<>(); SvgIcon icon = new SvgIcon("chatbot.svg"); icon.setColor("var(--lumo-primary-contrast-color)"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Render each message with a custom component that also shows its tagline. + chatAssistant.setMessagesRenderer( + new ComponentRenderer( + CustomChatMessage::new, + (component, message) -> { + ((CustomChatMessage) component).setMessage(message); + return component; + })); + + // Echo messages submitted by the user from the chat input. + chatAssistant.setSubmitListener( + se -> + chatAssistant.sendMessage( + CustomMessage.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(se.getValue()) + .tagline("Generated by user") + .build())); + + // Text area pre-filled with the answer that will be streamed. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant"); message.setSizeFull(); message.setValue(sampleText); - message.addKeyPressListener(ev->{ - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - chatAssistant.setMessagesRenderer(new ComponentRenderer(m -> { - return new CustomChatMessage(m); - }, - (component, m) -> { - ((CustomChatMessage) component).setMessage(m); - return component; - })); - chatAssistant.setSubmitListener(se -> { - chatAssistant.sendMessage(CustomMessage.builder().messageTime(LocalDateTime.now()) - .name("User").content(se.getValue()).tagline("Generated by user").build()); - }); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed text as a single assistant message. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - CustomMessage m = CustomMessage.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); + chat.addClickListener( + ev -> { + chatAssistant.sendMessage( + CustomMessage.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); + message.clear(); + }); - chatAssistant.sendMessage(m); - message.clear(); - }); + // Send an empty loading message and stream the answer into it word by word. Button chatWithThinking = new Button("Chat With Generative Thinking"); - chatWithThinking.addClickListener(ev -> { - String messageToSend = message.getValue(); - CustomMessage delayedMessage = CustomMessage.builder().loading(true).content("") - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build(); + chatWithThinking.addClickListener( + ev -> { + String answer = message.getValue(); + CustomMessage delayedMessage = + CustomMessage.builder() + .loading(true) + .content("") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build(); + chatAssistant.sendMessage(delayedMessage); - UI currentUI = UI.getCurrent(); - chatAssistant.sendMessage(delayedMessage); + UI currentUI = UI.getCurrent(); + CompletableFuture.runAsync( + () -> { + try { + TimeUnit.MILLISECONDS.sleep(500); + currentUI.access(() -> delayedMessage.setLoading(false)); + streamWords(answer) + .forEach( + word -> + currentUI.access( + () -> { + delayedMessage.setContent(delayedMessage.getContent() + word); + chatAssistant.updateMessage(delayedMessage); + })); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + message.clear(); + }); - CompletableFuture.runAsync(() -> { - try { - TimeUnit.MILLISECONDS.sleep(500); - currentUI.access(() -> { - delayedMessage.setLoading(false); - }); - streamWords(messageToSend) - .forEach(item -> { - currentUI.access(() -> { - delayedMessage.setContent(delayedMessage.getContent() + item); - chatAssistant.updateMessage(delayedMessage); - }); - }); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); + // Seed the conversation with a greeting and open the window. + chatAssistant.sendMessage( + CustomMessage.builder() + .content("Hello, I am here to assist you") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .tagline("Generated by assistant") + .build()); + chatAssistant.setOpened(true); - message.clear(); - }); - chatAssistant.sendMessage(CustomMessage.builder().content("Hello, I am here to assist you") - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").tagline("Generated by assistant").build()); - - add(message, chat, chatWithThinking, chatAssistant); + HorizontalLayout row = new HorizontalLayout(chat, chatWithThinking); + row.getStyle().set("flex-wrap", "wrap"); + + add(message, row, chatAssistant); } - - public Stream streamWords(String fullText) { + + /** Splits the text into words and emits them one at a time with a small random delay. */ + private Stream streamWords(String fullText) { if (fullText == null || fullText.isEmpty()) { - return Stream.empty(); + return Stream.empty(); } - - String[] words = fullText.split("\\s+"); - - return Arrays.stream(words) - .map(word -> { - try { - long delay = ThreadLocalRandom.current().nextLong(50, 250); - Thread.sleep(delay); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - return word + " "; - }); -} - + return Arrays.stream(fullText.split("\\s+")) + .map( + word -> { + try { + Thread.sleep(ThreadLocalRandom.current().nextLong(50, 250)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return word + " "; + }); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java index 1d2c639..1864773 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLazyLoadingDemo.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,88 +42,15 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Lazy Loading Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/lazy-loading-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantLazyLoadingDemo extends VerticalLayout { - - List messages = new ArrayList<>(Arrays.asList( - Message.builder().name("Claudius").content("I have sent to seek him and to find the body.\n" - + "How dangerous is it that this man goes loose!\n" - + "Yet must not we put the strong law on him.\n" - + "He's loved of the distracted multitude,\n" - + "Who like not in their judgment, but their eyes.\n" - + "And where 'tis so, th' offender's scourge is weighed,\n" - + "But never the offense. To bear all smooth and even,\n" - + "This sudden sending him away must seem\n" - + "Deliberate pause. Diseases desperate grown\n" - + "By desperate appliance are relieved,\n" - + "Or not at all.").build(), - Message.builder().name("Rosencrantz").content("(Enter)").build(), - Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), - Message.builder().name("Rosencrantz").content("Where the dead body is bestowed, my lord,\n" - + "We cannot get from him.").build(), - Message.builder().name("Claudius").content("But where is he?").build(), - Message.builder().name("Rosencrantz").content("Without, my lord; guarded, to know your pleasure.").build(), - Message.builder().name("Claudius").content("Bring him before us.").build(), - Message.builder().name("Rosencrantz").content("Ho, Guildenstern! Bring in my lord.").build(), - Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), - Message.builder().name("Hamlet").content("At supper.").build(), - Message.builder().name("Claudius").content("At supper? Where? ").build(), - Message.builder().name("Hamlet").content("Not where he eats, but where he is eaten. A certain \n" - + "convocation of politic worms are e'en at him. Your worm is your \n" - + "only emperor for diet. We fat all creatures else to fat us, and \n" - + "we fat ourselves for maggots. Your fat king and your lean beggar \n" - + "is but variable service- two dishes, but to one table. That's the \n" - + "end.").build(), - Message.builder().name("Claudius").content("Alas, alas!").build(), - Message.builder().name("Hamlet").content("A man may fish with the worm that hath eat of a king, and eat \n" - + "of the fish that hath fed of that worm.").build(), - Message.builder().name("Claudius").content("What dost thou mean by this?").build(), - Message.builder().name("Hamlet").content("Nothing but to show you how a king may go a progress through \n" - + "the guts of a beggar.\n" - + "").build(), - Message.builder().name("Claudius").content("Where is Polonius?").build(), - Message.builder().name("Hamlet").content("In heaven. Send thither to see. If your messenger find him not \n" - + "there, seek him i' th' other place yourself. But indeed, if you\n" - + "find him not within this month, you shall nose him as you go up \n" - + "the stair, into the lobby.").build(), - Message.builder().name("Claudius").content("Go seek him there.").build(), - Message.builder().name("Hamlet").content("He will stay till you come.").build(), - Message.builder().name("Claudius").content("Hamlet, this deed, for thine especial safety,- \n" - + "Which we do tender as we dearly grieve \n" - + "For that which thou hast done,- must send thee hence \n" - + "With fiery quickness. Therefore prepare thyself. \n" - + "The bark is ready and the wind at help, \n" - + "Th' associates tend, and everything is bent \n" - + "For England.").build(), - Message.builder().name("Hamlet").content("For England?").build(), - Message.builder().name("Claudius").content("Ay, Hamlet.").build(), - Message.builder().name("Hamlet").content("Good.").build(), - Message.builder().name("Claudius").content("So is it, if thou knew'st our purposes.").build(), - Message.builder().name("Hamlet").content("I see a cherub that sees them. But come, for England! \n" - + "Farewell, dear mother.").build(), - Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), - Message.builder().name("Hamlet").content("My mother! Father and mother is man and wife; man and wife is\n" - + "one flesh; and so, my mother. Come, for England!").build(), - Message.builder().name("Claudius").content("Follow him at foot; tempt him with speed aboard. \n" - + "Delay it not; I'll have him hence to-night. \n" - + "Away! for everything is seal'd and done\n" - + "That else leans on th' affair. Pray you make haste.").build(), - Message.builder().name("Claudius").content("And, England, if my love thou hold'st at aught,- \n" - + "As my great power thereof may give thee sense, \n" - + "Since yet thy cicatrice looks raw and red\n" - + "After the Danish sword, and thy free awe \n" - + "Pays homage to us,- thou mayst not coldly set \n" - + "Our sovereign process, which imports at full, \n" - + "By letters congruing to that effect, \n" - + "The present death of Hamlet. Do it, England; \n" - + "For like the hectic in my blood he rages, \n" - + "And thou must cure me. Till I know 'tis done, \n" - + "Howe'er my haps, my joys were ne'er begun. ").build() - )); - + public ChatAssistantLazyLoadingDemo() { + // Backing message list, served lazily by the data provider below. + List messages = createMessages(); + + // Create the assistant; the "small" class shrinks the message font (see demo CSS). ChatAssistant chatAssistant = new ChatAssistant<>(); chatAssistant.setClassName("small"); SvgIcon icon = new SvgIcon("chatbot.svg"); @@ -131,42 +58,23 @@ public ChatAssistantLazyLoadingDemo() { chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Feed messages through a lazy DataProvider, reporting the requested page as it loads. Span lazyLoadingData = new Span(); - DataProvider dataProvider = DataProvider.fromCallbacks(query->{ - lazyLoadingData.setText("Loading messages from: " + query.getOffset() + ", with limit: " + query.getLimit()); - return messages.stream().skip(query.getOffset()).limit(query.getLimit()); - }, query->{ - return messages.size(); - }); + DataProvider dataProvider = + DataProvider.fromCallbacks( + query -> { + lazyLoadingData.setText( + "Loading messages from: " + + query.getOffset() + + ", with limit: " + + query.getLimit()); + return messages.stream().skip(query.getOffset()).limit(query.getLimit()); + }, + query -> messages.size()); chatAssistant.setDataProvider(dataProvider); - - TextArea message = new TextArea(); - message.setLabel("Enter a message from the assistant"); - message.setSizeFull(); - message.addKeyPressListener(ev->{ - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); - - Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - Message m = Message.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build(); - messages.add(m); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); - message.clear(); - }); - chatAssistant.setSubmitListener(ev -> { - Message userMessage = Message.builder().messageTime(LocalDateTime.now()) - .name("User").content(ev.getValue()).build(); - messages.add(userMessage); - dataProvider.refreshAll(); - chatAssistant.scrollToEnd(); - }); + // Replace the default header with a custom title bar and minimize button. Icon minimize = VaadinIcon.MINUS.create(); minimize.addClickListener(ev -> chatAssistant.setOpened(!chatAssistant.isOpened())); Span title = new Span("Customized Assistant Header"); @@ -175,7 +83,179 @@ public ChatAssistantLazyLoadingDemo() { headerBar.setWidthFull(); chatAssistant.setHeaderComponent(headerBar); + // Text area used to compose the assistant's answer. + TextArea message = new TextArea(); + message.setLabel("Enter a message from the assistant"); + message.setSizeFull(); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + + // With a custom DataProvider, append to the list and refresh instead of using sendMessage. + Button chat = new Button("Chat"); + chat.addClickListener( + ev -> { + messages.add( + Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); + message.clear(); + }); + + // Messages typed in the chat input are appended the same way. + chatAssistant.setSubmitListener( + ev -> { + messages.add( + Message.builder() + .messageTime(LocalDateTime.now()) + .name("User") + .content(ev.getValue()) + .build()); + dataProvider.refreshAll(); + chatAssistant.scrollToEnd(); + }); + + chatAssistant.setOpened(true); + add(message, lazyLoadingData, chat, chatAssistant); } - + + /** Sample conversation (an excerpt from Hamlet) used to populate the lazy data provider. */ + private static List createMessages() { + return new ArrayList<>( + Arrays.asList( + Message.builder() + .name("Claudius") + .content( + "I have sent to seek him and to find the body.\n" + + "How dangerous is it that this man goes loose!\n" + + "Yet must not we put the strong law on him.\n" + + "He's loved of the distracted multitude,\n" + + "Who like not in their judgment, but their eyes.\n" + + "And where 'tis so, th' offender's scourge is weighed,\n" + + "But never the offense. To bear all smooth and even,\n" + + "This sudden sending him away must seem\n" + + "Deliberate pause. Diseases desperate grown\n" + + "By desperate appliance are relieved,\n" + + "Or not at all.") + .build(), + Message.builder().name("Rosencrantz").content("(Enter)").build(), + Message.builder().name("Claudius").content("How now, what hath befall'n?").build(), + Message.builder() + .name("Rosencrantz") + .content("Where the dead body is bestowed, my lord,\n" + "We cannot get from him.") + .build(), + Message.builder().name("Claudius").content("But where is he?").build(), + Message.builder() + .name("Rosencrantz") + .content("Without, my lord; guarded, to know your pleasure.") + .build(), + Message.builder().name("Claudius").content("Bring him before us.").build(), + Message.builder() + .name("Rosencrantz") + .content("Ho, Guildenstern! Bring in my lord.") + .build(), + Message.builder().name("Claudius").content("Now, Hamlet, where's Polonius?").build(), + Message.builder().name("Hamlet").content("At supper.").build(), + Message.builder().name("Claudius").content("At supper? Where? ").build(), + Message.builder() + .name("Hamlet") + .content( + "Not where he eats, but where he is eaten. A certain \n" + + "convocation of politic worms are e'en at him. Your worm is your \n" + + "only emperor for diet. We fat all creatures else to fat us, and \n" + + "we fat ourselves for maggots. Your fat king and your lean beggar \n" + + "is but variable service- two dishes, but to one table. That's the \n" + + "end.") + .build(), + Message.builder().name("Claudius").content("Alas, alas!").build(), + Message.builder() + .name("Hamlet") + .content( + "A man may fish with the worm that hath eat of a king, and eat \n" + + "of the fish that hath fed of that worm.") + .build(), + Message.builder().name("Claudius").content("What dost thou mean by this?").build(), + Message.builder() + .name("Hamlet") + .content( + "Nothing but to show you how a king may go a progress through \n" + + "the guts of a beggar.\n" + + "") + .build(), + Message.builder().name("Claudius").content("Where is Polonius?").build(), + Message.builder() + .name("Hamlet") + .content( + "In heaven. Send thither to see. If your messenger find him not \n" + + "there, seek him i' th' other place yourself. But indeed, if you\n" + + "find him not within this month, you shall nose him as you go up \n" + + "the stair, into the lobby.") + .build(), + Message.builder().name("Claudius").content("Go seek him there.").build(), + Message.builder().name("Hamlet").content("He will stay till you come.").build(), + Message.builder() + .name("Claudius") + .content( + "Hamlet, this deed, for thine especial safety,- \n" + + "Which we do tender as we dearly grieve \n" + + "For that which thou hast done,- must send thee hence \n" + + "With fiery quickness. Therefore prepare thyself. \n" + + "The bark is ready and the wind at help, \n" + + "Th' associates tend, and everything is bent \n" + + "For England.") + .build(), + Message.builder().name("Hamlet").content("For England?").build(), + Message.builder().name("Claudius").content("Ay, Hamlet.").build(), + Message.builder().name("Hamlet").content("Good.").build(), + Message.builder() + .name("Claudius") + .content("So is it, if thou knew'st our purposes.") + .build(), + Message.builder() + .name("Hamlet") + .content( + "I see a cherub that sees them. But come, for England! \n" + + "Farewell, dear mother.") + .build(), + Message.builder().name("Claudius").content("Thy loving father, Hamlet.").build(), + Message.builder() + .name("Hamlet") + .content( + "My mother! Father and mother is man and wife; man and wife is\n" + + "one flesh; and so, my mother. Come, for England!") + .build(), + Message.builder() + .name("Claudius") + .content( + "Follow him at foot; tempt him with speed aboard. \n" + + "Delay it not; I'll have him hence to-night. \n" + + "Away! for everything is seal'd and done\n" + + "That else leans on th' affair. Pray you make haste.") + .build(), + Message.builder() + .name("Claudius") + .content( + "And, England, if my love thou hold'st at aught,- \n" + + "As my great power thereof may give thee sense, \n" + + "Since yet thy cicatrice looks raw and red\n" + + "After the Danish sword, and thy free awe \n" + + "Pays homage to us,- thou mayst not coldly set \n" + + "Our sovereign process, which imports at full, \n" + + "By letters congruing to that effect, \n" + + "The present death of Hamlet. Do it, England; \n" + + "For like the hectic in my blood he rages, \n" + + "And thou must cure me. Till I know 'tis done, \n" + + "Howe'er my haps, my joys were ne'er begun. ") + .build())); + } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java new file mode 100644 index 0000000..5b2420d --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantLogicTest.java @@ -0,0 +1,158 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.chatassistant.model.FabVariant; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import org.junit.Test; + +/** + * Unit tests for the pure-Java logic in {@link ChatAssistant} that does not require an attached UI: + * FAB margin parsing, unread-message clamping, screen-size listener argument validation, and the + * {@link FabVariant} mapping. These are exercised on an unattached component (no UI/DOM), mirroring + * the lightweight style of {@code SerializationTest}. + */ +public class ChatAssistantLogicTest { + + private ChatAssistant newChatAssistant() { + return new ChatAssistant<>(); + } + + // parseFabMargin ----------------------------------------------------------- + + @Test + public void parseFabMargin_null_returnsDefault() { + assertEquals(ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin(null)); + } + + @Test + public void parseFabMargin_plainNumber_isParsed() { + assertEquals(30, newChatAssistant().parseFabMargin("30")); + } + + @Test + public void parseFabMargin_withPxSuffixAndWhitespace_isParsed() { + assertEquals(42, newChatAssistant().parseFabMargin(" 42px ")); + } + + @Test + public void parseFabMargin_invalid_returnsDefault() { + assertEquals( + ChatAssistant.DEFAULT_FAB_MARGIN, newChatAssistant().parseFabMargin("not-a-number")); + } + + // setUnreadMessages clamping ---------------------------------------------- + + @Test + public void setUnreadMessages_negative_clampsToZero() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(-5); + assertEquals(0, ca.getUnreadMessages()); + } + + @Test + public void setUnreadMessages_aboveMax_clampsTo99() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(150); + assertEquals(99, ca.getUnreadMessages()); + } + + @Test + public void setUnreadMessages_inRange_isUnchanged() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(7); + assertEquals(7, ca.getUnreadMessages()); + } + + @Test + public void setUnreadMessages_boundaries_areKept() { + ChatAssistant ca = newChatAssistant(); + ca.setUnreadMessages(0); + assertEquals(0, ca.getUnreadMessages()); + ca.setUnreadMessages(99); + assertEquals(99, ca.getUnreadMessages()); + } + + // addScreenSizeListener validation ---------------------------------------- + + @Test + public void addScreenSizeListener_nullListener_throws() { + ChatAssistant ca = newChatAssistant(); + assertThrows(NullPointerException.class, () -> ca.addScreenSizeListener(100, 100, null)); + } + + @Test + public void addScreenSizeListener_bothThresholdsNull_throws() { + ChatAssistant ca = newChatAssistant(); + assertThrows( + IllegalArgumentException.class, () -> ca.addScreenSizeListener(null, null, ev -> {})); + } + + @Test + public void addScreenSizeListener_nonPositiveThreshold_throws() { + ChatAssistant ca = newChatAssistant(); + assertThrows(IllegalArgumentException.class, () -> ca.addScreenSizeListener(0, null, ev -> {})); + } + + @Test + public void addScreenSizeListener_validThreshold_returnsRegistration() { + ChatAssistant ca = newChatAssistant(); + assertNotNull(ca.addScreenSizeListener(200, null, ev -> {})); + } + + // FabVariant mapping ------------------------------------------------------- + + @Test + public void fabVariant_everyConstant_mapsToButtonVariant() { + for (FabVariant variant : FabVariant.values()) { + assertNotNull("Missing mapping for " + variant, variant.toButtonVariant()); + } + } + + @Test + public void fabVariant_sizeClassification_isCorrect() { + assertTrue(FabVariant.SMALL.isSizeVariant()); + assertTrue(FabVariant.LARGE.isSizeVariant()); + assertFalse(FabVariant.SUCCESS.isSizeVariant()); + assertFalse(FabVariant.ERROR.isSizeVariant()); + assertFalse(FabVariant.LUMO_CONTRAST.isSizeVariant()); + assertFalse(FabVariant.PRIMARY.isSizeVariant()); + } + + @Test + public void fabVariant_auraAccentClasses_areExposedForSuccessAndError() { + // Aura styles the accent colors via CSS class, so success/error must carry an Aura class. + assertEquals("aura-accent-green", FabVariant.SUCCESS.getAuraClass()); + assertEquals("aura-accent-red", FabVariant.ERROR.getAuraClass()); + // Cross-theme (PRIMARY) and Lumo-only (CONTRAST) variants need no Aura class. + assertNull(FabVariant.PRIMARY.getAuraClass()); + assertNull(FabVariant.LUMO_CONTRAST.getAuraClass()); + // Size variants don't apply a button theme at all. + assertNull(FabVariant.SMALL.getAuraClass()); + assertNull(FabVariant.LARGE.getAuraClass()); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java index 2a665b9..85ea78c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantMarkdownDemo.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,39 +34,59 @@ @DemoSource(sourcePosition = SourcePosition.PRIMARY) @PageTitle("Markdown Demo") -@SuppressWarnings("serial") @Route(value = "chat-assistant/markdown-demo", layout = ChatAssistantDemoView.class) @CssImport("./styles/chat-assistant-styles-demo.css") public class ChatAssistantMarkdownDemo extends VerticalLayout { - + public ChatAssistantMarkdownDemo() { + // Pass true to the constructor to render message content as Markdown. ChatAssistant chatAssistant = new ChatAssistant<>(true); SvgIcon icon = new SvgIcon("chatbot.svg"); icon.setColor("var(--lumo-primary-contrast-color)"); chatAssistant.setFabIcon(icon); chatAssistant.setWindowWidth("400px"); chatAssistant.setWindowHeight("400px"); + + // Text area used to compose the assistant's answer, prefilled with a Markdown sample to send. TextArea message = new TextArea(); message.setLabel("Enter a message from the assistant (try using Markdown)"); message.setSizeFull(); - message.addKeyPressListener(ev->{ - if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { - chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); - } - }); - message.addBlurListener(ev->chatAssistant.clearWhoIsTyping()); + message.setValue( + "# Heading\n\n" + + "Some **bold** and *italic* text, with `inline code`.\n\n" + + "- First item\n" + + "- Second item\n"); + message.addKeyPressListener( + ev -> { + if (Strings.isNullOrEmpty(chatAssistant.getWhoIsTyping())) { + chatAssistant.setWhoIsTyping("Assistant is generating an answer ..."); + } + }); + message.addBlurListener(ev -> chatAssistant.clearWhoIsTyping()); + // Send the composed Markdown as an assistant message. Button chat = new Button("Chat"); - chat.addClickListener(ev -> { - Message m = Message.builder().content(message.getValue()).messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build(); + chat.addClickListener( + ev -> { + chatAssistant.sendMessage( + Message.builder() + .content(message.getValue()) + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + message.clear(); + }); - chatAssistant.sendMessage(m); - message.clear(); - }); - chatAssistant.sendMessage(Message.builder().content("**Hello, I am here to assist you**") - .messageTime(LocalDateTime.now()) - .name("Assistant").avatar("chatbot.png").build()); + // Seed the conversation with a Markdown-formatted greeting and open the window. + chatAssistant.sendMessage( + Message.builder() + .content("**Hello, I am here to assist you**") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + chatAssistant.setOpened(true); add(message, chat, chatAssistant); } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java new file mode 100644 index 0000000..d0b924b --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantModeDemo.java @@ -0,0 +1,97 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.chatassistant; + +import com.flowingcode.vaadin.addons.chatassistant.model.ChatAssistantMode; +import com.flowingcode.vaadin.addons.chatassistant.model.Message; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.demo.SourcePosition; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.icon.SvgIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDateTime; + +@DemoSource(sourcePosition = SourcePosition.PRIMARY) +@PageTitle("Modes & Responsive Demo") +@Route(value = "chat-assistant/mode-demo", layout = ChatAssistantDemoView.class) +@CssImport("./styles/chat-assistant-styles-demo.css") +public class ChatAssistantModeDemo extends VerticalLayout { + + public ChatAssistantModeDemo() { + SvgIcon icon = new SvgIcon("chatbot.svg"); + icon.setColor("var(--lumo-primary-contrast-color)"); + + // Build the assistant with auto-switching enabled: setting a breakpoint makes it switch to + // mobile (full-screen dialog) below 768px and back to desktop (anchored popover) above it. + ChatAssistant chatAssistant = + ChatAssistant.builder().fabIcon(icon).mobileBreakpoint(768).build(); + chatAssistant.setWindowWidth("400px"); + chatAssistant.setWindowHeight("400px"); + + // React to every mode change, whether automatic or manual. + chatAssistant.addModeChangedListener( + ev -> Notification.show("Switched to " + ev.getMode() + " mode")); + + // React to the chat window's own size crossing a 500px width threshold (independent of the + // viewport breakpoint above). Fires once on registration and then on every crossing. + chatAssistant.addScreenSizeListener( + 500, + null, + ev -> Notification.show("Chat window is now " + ev.getDirection() + " 500px wide")); + + // Switch the mode manually (only sticks while auto-switching is disabled). + Button mobile = new Button("Set mobile", ev -> chatAssistant.setMode(ChatAssistantMode.MOBILE)); + Button desktop = + new Button("Set desktop", ev -> chatAssistant.setMode(ChatAssistantMode.DESKTOP)); + + // Freeze/resume automatic switching on the configured breakpoint. + Button toggleSwitching = + new Button( + "Toggle auto switching", + ev -> { + chatAssistant.setMobileModeSwitchingEnabled( + !chatAssistant.isMobileModeSwitchingEnabled()); + Notification.show("Auto switching: " + chatAssistant.isMobileModeSwitchingEnabled()); + }); + + // Move the FAB back to its configured corner after it has been dragged. + Button reset = new Button("Reset FAB position", ev -> chatAssistant.resetFabPosition()); + + HorizontalLayout controls = new HorizontalLayout(mobile, desktop, toggleSwitching, reset); + controls.getStyle().set("flex-wrap", "wrap"); + + // Seed the conversation with a greeting and open the window. + chatAssistant.sendMessage( + Message.builder() + .content("Resize the window to switch modes.") + .messageTime(LocalDateTime.now()) + .name("Assistant") + .avatar("chatbot.png") + .build()); + chatAssistant.setOpened(true); + + add(controls, chatAssistant); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java index 47e2627..b159f55 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomChatMessage.java @@ -1,10 +1,28 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ package com.flowingcode.vaadin.addons.chatassistant; import com.vaadin.flow.component.html.Span; -@SuppressWarnings("serial") public class CustomChatMessage extends ChatMessage { - + private Span tagline = new Span(); public CustomChatMessage(CustomMessage message) { @@ -14,7 +32,4 @@ public CustomChatMessage(CustomMessage message) { tagline.getStyle().set("font-size", "x-small"); this.add(tagline); } - - - } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java index d121cce..2b3d1c2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/CustomMessage.java @@ -1,3 +1,22 @@ +/*- + * #%L + * Chat Assistant Add-on + * %% + * Copyright (C) 2023 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ package com.flowingcode.vaadin.addons.chatassistant; import com.flowingcode.vaadin.addons.chatassistant.model.Message; @@ -6,13 +25,11 @@ import lombok.Setter; import lombok.experimental.SuperBuilder; -@SuppressWarnings("serial") @EqualsAndHashCode(callSuper = true) @Getter @Setter @SuperBuilder public class CustomMessage extends Message { - + private String tagline; - } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java index 622b93d..9908fbb 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/DemoView.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,6 @@ import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.Route; -@SuppressWarnings("serial") @Route("") public class DemoView extends VerticalLayout implements BeforeEnterObserver { diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java index 8ee9f3e..f313d7c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/AbstractViewTest.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java index 11f6202..91e30ac 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/BasicIT.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,6 @@ import com.flowingcode.vaadin.addons.chatassistant.it.po.ChatBubbleElement; import com.vaadin.flow.component.button.testbench.ButtonElement; import com.vaadin.flow.component.html.testbench.NativeButtonElement; -import com.vaadin.flow.component.html.testbench.ParagraphElement; import com.vaadin.flow.component.notification.testbench.NotificationElement; import com.vaadin.flow.component.textfield.testbench.TextAreaElement; import com.vaadin.testbench.TestBenchElement; @@ -42,7 +41,7 @@ public void sendMessageFromUser() { String notificationMessage = $(NotificationElement.class).waitForFirst().getText(); Assert.assertEquals("hello", notificationMessage); } - + @Test public void sendMessageFromAssistant() { ChatAssistantElement element = $(ChatAssistantElement.class).first(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java index 836271c..49e0af1 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/ViewIT.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,11 +22,11 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -import static org.junit.Assert.assertThat; import com.vaadin.testbench.TestBenchElement; import org.hamcrest.Description; import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; import org.hamcrest.TypeSafeDiagnosingMatcher; import org.junit.Test; @@ -59,6 +59,6 @@ protected boolean matchesSafely(TestBenchElement item, Description mismatchDescr @Test public void componentWorks() { TestBenchElement element = $("chat-bot").first(); - assertThat(element, hasBeenUpgradedToCustomElement); + MatcherAssert.assertThat(element, hasBeenUpgradedToCustomElement); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java index dac91a5..ce2f8bf 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatAssistantElement.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java index ad46534..c28e901 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/it/po/ChatBubbleElement.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java index 79d2489..4e10e1a 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/test/SerializationTest.java @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,7 +45,7 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - ChatAssistant chatAssistant = new ChatAssistant(); + ChatAssistant chatAssistant = new ChatAssistant(); chatAssistant.sendMessage(Message.builder().build()); testSerializationOf(chatAssistant); } catch (Exception e) { diff --git a/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css b/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css index d161b31..1045add 100644 --- a/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css +++ b/src/test/resources/META-INF/frontend/styles/chat-assistant-styles-demo.css @@ -2,7 +2,7 @@ * #%L * Chat Assistant Add-on * %% - * Copyright (C) 2023 - 2024 Flowing Code + * Copyright (C) 2023 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/resources/META-INF/resources/chatbot.svg b/src/test/resources/META-INF/resources/chatbot.svg index 5acc55a..159f2ab 100644 --- a/src/test/resources/META-INF/resources/chatbot.svg +++ b/src/test/resources/META-INF/resources/chatbot.svg @@ -1,4 +1,24 @@ + +