fix: make dashboard widgets closable (#14) #17

Merged
pitfriedrich merged 1 commits from ai/issue-14-closable-widgets into main 2026-07-26 18:20:27 +00:00
8 changed files with 185 additions and 37 deletions
Showing only changes of commit 7d23eef81b - Show all commits
+2 -1
View File
@@ -25,7 +25,8 @@ public enum Fa {
ADD("fa-solid", "fa-plus"), ADD("fa-solid", "fa-plus"),
REMOVE("fa-solid", "fa-trash"), REMOVE("fa-solid", "fa-trash"),
RESET("fa-solid", "fa-arrow-rotate-left"), RESET("fa-solid", "fa-arrow-rotate-left"),
DRAG("fa-solid", "fa-grip-vertical"); DRAG("fa-solid", "fa-grip-vertical"),
CLOSE("fa-solid", "fa-xmark");
private final String[] classes; private final String[] classes;
@@ -1,7 +1,10 @@
package com.example.components; package com.example.components;
import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.shared.Registration;
import java.util.UUID; import java.util.UUID;
@@ -12,7 +15,10 @@ import java.util.UUID;
* <p> * <p>
* Dragging starts from the grip handle only, never from the item body, so the * Dragging starts from the grip handle only, never from the item body, so the
* components inside an item stay interactive (see * components inside an item stay interactive (see
* {@link GridStackLayout#setDragHandle(String)}). * {@link GridStackLayout#setDragHandle(String)}). A close button sits next to
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
* is enough to unregister the widget client-side, since grid-stack.ts observes
* its own childList.
*/ */
public class GridStackItem extends Div { public class GridStackItem extends Div {
@@ -20,8 +26,14 @@ public class GridStackItem extends Div {
* {@link GridStackLayout}'s {@code handle} option. */ * {@link GridStackLayout}'s {@code handle} option. */
public static final String DRAG_HANDLE_CLASS = "dialect-drag-handle"; public static final String DRAG_HANDLE_CLASS = "dialect-drag-handle";
/** Marker class for the close button; {@code styles.css} keys both its own
* styling and the grip's offset off it. */
public static final String CLOSE_BUTTON_CLASS = "dialect-close-button";
private final Div content = new Div(); private final Div content = new Div();
private final Div dragHandle = new Div(); private final Div dragHandle = new Div();
private final Div closeButton = new Div();
private boolean closable = true;
public GridStackItem(Component... content) { public GridStackItem(Component... content) {
this(UUID.randomUUID().toString(), 0, 0, 4, 3, content); this(UUID.randomUUID().toString(), 0, 0, 4, 3, content);
@@ -51,6 +63,72 @@ public class GridStackItem extends Div {
dragHandle.getElement().setAttribute("title", label); dragHandle.getElement().setAttribute("title", label);
dragHandle.getElement().setAttribute("aria-label", label); dragHandle.getElement().setAttribute("aria-label", label);
getElement().appendChild(dragHandle.getElement()); getElement().appendChild(dragHandle.getElement());
// Sibling of the content wrapper for the same reason as the grip, and
// outside the drag-handle selector so clicking it never starts a drag.
closeButton.addClassName(CLOSE_BUTTON_CLASS);
closeButton.add(Fa.CLOSE.create());
String closeLabel = getTranslation("gridstack.close");
closeButton.getElement().setAttribute("title", closeLabel);
closeButton.getElement().setAttribute("aria-label", closeLabel);
closeButton.getElement().setAttribute("role", "button");
closeButton.getElement().setAttribute("tabindex", "0");
closeButton.getElement().addEventListener("click", e -> close(true));
closeButton.getElement().addEventListener("keydown", e -> close(true))
.setFilter("event.key === 'Enter' || event.key === ' '");
getElement().appendChild(closeButton.getElement());
}
/**
* Detaches this item from its layout and notifies
* {@link #addCloseListener(ComponentEventListener) close listeners}. Called
* by the close button, and usable server-side to close an item
* programmatically.
*/
public void close() {
close(false);
}
private void close(boolean fromClient) {
// Detach first so listeners observe the layout they are about to see —
// getLayout() on the grid no longer counts this item.
getElement().removeFromParent();
fireEvent(new CloseEvent(this, fromClient));
}
/** Shows or hides the close button (shown by default). */
public GridStackItem setClosable(boolean closable) {
if (closable == this.closable) {
return this;
}
this.closable = closable;
if (closable) {
getElement().appendChild(closeButton.getElement());
} else {
closeButton.getElement().removeFromParent();
}
return this;
}
public boolean isClosable() {
return closable;
}
/** The close button element. Exposed for restyling, the same way
* {@link #getDragHandle()} is. */
public Div getCloseButton() {
return closeButton;
}
public Registration addCloseListener(ComponentEventListener<CloseEvent> listener) {
return addListener(CloseEvent.class, listener);
}
/** Fired after the item has been removed from its {@link GridStackLayout}. */
public static class CloseEvent extends ComponentEvent<GridStackItem> {
CloseEvent(GridStackItem source, boolean fromClient) {
super(source, fromClient);
}
} }
public void add(Component... components) { public void add(Component... components) {
@@ -18,14 +18,13 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List; import java.util.List;
/** /**
* Showcase for {@link GridStackLayout}: a dashboard of draggable/resizable * Showcase for {@link GridStackLayout}: a dashboard of draggable/resizable
* cards whose layout is persisted per browser, plus controls to add, remove * cards whose layout is persisted per browser, plus controls to add widgets and
* and reset widgets at runtime. * reset the layout at runtime. Widgets are removed by their own close button
* (see {@link GridStackItem#setClosable(boolean)}), not from the toolbar.
*/ */
@Route("gridstack") @Route("gridstack")
public class GridStackView extends VerticalLayout implements HasDynamicTitle { public class GridStackView extends VerticalLayout implements HasDynamicTitle {
@@ -33,7 +32,6 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "gridstack-demo"; private static final String STORAGE_KEY = "gridstack-demo";
private final GridStackLayout grid = new GridStackLayout(); private final GridStackLayout grid = new GridStackLayout();
private final Deque<GridStackItem> extraWidgets = new ArrayDeque<>();
private final Span status = new Span(); private final Span status = new Span();
private int extraWidgetCount; private int extraWidgetCount;
@@ -67,13 +65,10 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
e -> addWidget()); e -> addWidget());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY); add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button remove = new Button(getTranslation("gridstack.removeWidget"), Fa.REMOVE.create(),
e -> removeWidget());
Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(), Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(),
e -> grid.resetLayout()); e -> grid.resetLayout());
HorizontalLayout toolbar = new HorizontalLayout(add, remove, reset, status); HorizontalLayout toolbar = new HorizontalLayout(add, reset, status);
toolbar.setPadding(false); toolbar.setPadding(false);
toolbar.setWidthFull(); toolbar.setWidthFull();
toolbar.setAlignItems(Alignment.CENTER); toolbar.setAlignItems(Alignment.CENTER);
@@ -83,21 +78,13 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
private void addWidget() { private void addWidget() {
// The id must stay stable across reloads for the saved layout to match // The id must stay stable across reloads for the saved layout to match
// it again, so it is derived from a counter rather than a random UUID. // it again, so it is derived from a counter rather than a random UUID.
// The counter only ever grows: closing a widget must not hand its id to
// the next one, or the new widget would inherit the closed one's saved
// position.
extraWidgetCount++; extraWidgetCount++;
String title = getTranslation("gridstack.widget", extraWidgetCount); String title = getTranslation("gridstack.widget", extraWidgetCount);
GridStackItem item = new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2, grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))); new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
extraWidgets.push(item);
grid.add(item);
}
private void removeWidget() {
GridStackItem item = extraWidgets.poll();
if (item == null) {
return;
}
grid.remove(item);
extraWidgetCount--;
} }
private Component lineChart() { private Component lineChart() {
@@ -176,9 +176,45 @@ vaadin-app-layout::part(content) {
opacity: 1; opacity: 1;
} }
/* Touch devices never hover — keep the grip permanently visible there. */ /* Close button (GridStackItem#setClosable): same affordance as the grip, in
the outermost corner slot. The grip steps aside only when it is there, so a
non-closable item keeps its grip flush with the corner. */
.dialect-close-button {
position: absolute;
top: 14px;
right: 16px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--dialect-ink);
opacity: 0;
cursor: pointer;
transition: opacity 120ms ease;
}
.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle {
right: 48px;
}
.grid-stack-item:hover > .dialect-close-button,
.dialect-close-button:focus-visible {
opacity: 0.65;
}
.dialect-close-button:hover,
.dialect-close-button:active {
opacity: 1;
color: var(--dialect-primary);
}
/* Touch devices never hover — keep the corner controls permanently visible. */
@media (pointer: coarse) { @media (pointer: coarse) {
.dialect-drag-handle { .dialect-drag-handle,
.dialect-close-button {
opacity: 0.65; opacity: 0.65;
} }
} }
@@ -24,15 +24,15 @@ card.registration=Registrierung
card.employees=Mitarbeiter card.employees=Mitarbeiter
card.gridstackHint=Bedienung card.gridstackHint=Bedienung
gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt. gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern, mit dem X oben rechts schließen. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt.
gridstack.addWidget=Widget hinzufügen gridstack.addWidget=Widget hinzufügen
gridstack.removeWidget=Letztes entfernen
gridstack.reset=Layout zurücksetzen gridstack.reset=Layout zurücksetzen
gridstack.status=Layout geändert {0} Widgets gridstack.status=Layout geändert {0} Widgets
gridstack.statusInitial=Layout unverändert gridstack.statusInitial=Layout unverändert
gridstack.widget=Widget {0} gridstack.widget=Widget {0}
gridstack.widgetText=Frei platzierbare Karte. gridstack.widgetText=Frei platzierbare Karte.
gridstack.dragHandle=Verschieben gridstack.dragHandle=Verschieben
gridstack.close=Schließen
chart.revenueSeries=Umsatz 2026 chart.revenueSeries=Umsatz 2026
chart.pointClick=Serie {0}, Punkt {1} chart.pointClick=Serie {0}, Punkt {1}
@@ -24,15 +24,15 @@ card.registration=Registration
card.employees=Employees card.employees=Employees
card.gridstackHint=How it works card.gridstackHint=How it works
gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner. The layout is stored in your browser and restored on your next visit. gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner, close them with the X in the top right corner. The layout is stored in your browser and restored on your next visit.
gridstack.addWidget=Add widget gridstack.addWidget=Add widget
gridstack.removeWidget=Remove last
gridstack.reset=Reset layout gridstack.reset=Reset layout
gridstack.status=Layout changed {0} widgets gridstack.status=Layout changed {0} widgets
gridstack.statusInitial=Layout unchanged gridstack.statusInitial=Layout unchanged
gridstack.widget=Widget {0} gridstack.widget=Widget {0}
gridstack.widgetText=Freely placeable card. gridstack.widgetText=Freely placeable card.
gridstack.dragHandle=Move gridstack.dragHandle=Move
gridstack.close=Close
chart.revenueSeries=Revenue 2026 chart.revenueSeries=Revenue 2026
chart.pointClick=Series {0}, point {1} chart.pointClick=Series {0}, point {1}
@@ -24,15 +24,15 @@ card.registration=Registro
card.employees=Empleados card.employees=Empleados
card.gridstackHint=Cómo funciona card.gridstackHint=Cómo funciona
gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha y cambia su tamaño desde la esquina inferior derecha. El diseño se guarda en el navegador y se restaura en la próxima visita. gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. El diseño se guarda en el navegador y se restaura en la próxima visita.
gridstack.addWidget=Añadir widget gridstack.addWidget=Añadir widget
gridstack.removeWidget=Quitar el último
gridstack.reset=Restablecer diseño gridstack.reset=Restablecer diseño
gridstack.status=Diseño modificado {0} widgets gridstack.status=Diseño modificado {0} widgets
gridstack.statusInitial=Diseño sin cambios gridstack.statusInitial=Diseño sin cambios
gridstack.widget=Widget {0} gridstack.widget=Widget {0}
gridstack.widgetText=Tarjeta de colocación libre. gridstack.widgetText=Tarjeta de colocación libre.
gridstack.dragHandle=Mover gridstack.dragHandle=Mover
gridstack.close=Cerrar
chart.revenueSeries=Ingresos 2026 chart.revenueSeries=Ingresos 2026
chart.pointClick=Serie {0}, punto {1} chart.pointClick=Serie {0}, punto {1}
@@ -5,13 +5,18 @@ import com.example.components.GridStackItem;
import com.example.components.GridStackLayout; import com.example.components.GridStackLayout;
import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages; import com.vaadin.browserless.ViewPackages;
import com.vaadin.browserless.internal.ElementUtilsKt;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -32,7 +37,7 @@ class GridStackViewTest extends SpringBrowserlessTest {
} }
@Test @Test
void addAndRemoveWidget_changesItemCount() { void addAndCloseWidget_changesItemCount() {
navigate(GridStackView.class); navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first(); GridStackLayout grid = $view(GridStackLayout.class).first();
@@ -41,18 +46,59 @@ class GridStackViewTest extends SpringBrowserlessTest {
button("gridstack.addWidget").click(); button("gridstack.addWidget").click();
assertEquals(initial + 1, grid.getLayout().size()); assertEquals(initial + 1, grid.getLayout().size());
button("gridstack.removeWidget").click(); clickCloseButton($view(GridStackItem.class).last());
assertEquals(initial, grid.getLayout().size()); assertEquals(initial, grid.getLayout().size());
} }
@Test @Test
void removeWidget_onDefaultLayout_keepsStaticWidgets() { void closeButton_removesTheClickedWidgetOnly() {
navigate(GridStackView.class); navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first(); GridStackLayout grid = $view(GridStackLayout.class).first();
button("gridstack.removeWidget").click(); GridStackItem first = $view(GridStackItem.class).first();
String closedId = first.getItemId();
assertEquals(4, grid.getLayout().size(), "static widgets must not be removable"); clickCloseButton(first);
List<GridStackItem.Position> layout = grid.getLayout();
assertEquals(3, layout.size());
assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())),
"the closed widget must be gone from the layout");
}
@Test
void closeListener_firesOnceItemIsDetached() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem item = $view(GridStackItem.class).first();
int[] fired = {0};
item.addCloseListener(e -> fired[0]++);
item.close();
assertEquals(1, fired[0]);
assertEquals(3, grid.getLayout().size());
}
@Test
void nonClosableItem_hasNoCloseButton() {
navigate(GridStackView.class);
GridStackItem item = $view(GridStackItem.class).first();
assertTrue(item.isClosable(), "items are closable by default");
item.setClosable(false);
assertFalse(item.isClosable());
assertFalse(item.getChildren().anyMatch(child -> child == item.getCloseButton()));
}
/** Fires the DOM click the close button listens for, rather than calling
* {@code close()} directly, so the button's own wiring is covered too. */
private void clickCloseButton(GridStackItem item) {
Div closeButton = item.getCloseButton();
ElementUtilsKt._fireDomEvent(closeButton.getElement(),
new DomEvent(closeButton.getElement(), "click", JacksonUtils.createObjectNode()));
} }
private Button button(String translationKey) { private Button button(String translationKey) {