Compare commits
2 Commits
2e2c9aec3b
...
4de757df99
| Author | SHA1 | Date | |
|---|---|---|---|
| 4de757df99 | |||
| 7d23eef81b |
@@ -25,7 +25,8 @@ public enum Fa {
|
||||
ADD("fa-solid", "fa-plus"),
|
||||
REMOVE("fa-solid", "fa-trash"),
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.example.components;
|
||||
|
||||
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.shared.Registration;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -12,7 +15,10 @@ import java.util.UUID;
|
||||
* <p>
|
||||
* Dragging starts from the grip handle only, never from the item body, so the
|
||||
* 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 {
|
||||
|
||||
@@ -20,8 +26,14 @@ public class GridStackItem extends Div {
|
||||
* {@link GridStackLayout}'s {@code handle} option. */
|
||||
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 dragHandle = new Div();
|
||||
private final Div closeButton = new Div();
|
||||
private boolean closable = true;
|
||||
|
||||
public GridStackItem(Component... 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("aria-label", label);
|
||||
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) {
|
||||
|
||||
@@ -18,14 +18,13 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.router.HasDynamicTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Showcase for {@link GridStackLayout}: a dashboard of draggable/resizable
|
||||
* cards whose layout is persisted per browser, plus controls to add, remove
|
||||
* and reset widgets at runtime.
|
||||
* cards whose layout is persisted per browser, plus controls to add widgets and
|
||||
* reset the layout at runtime. Widgets are removed by their own close button
|
||||
* (see {@link GridStackItem#setClosable(boolean)}), not from the toolbar.
|
||||
*/
|
||||
@Route("gridstack")
|
||||
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 final GridStackLayout grid = new GridStackLayout();
|
||||
private final Deque<GridStackItem> extraWidgets = new ArrayDeque<>();
|
||||
private final Span status = new Span();
|
||||
private int extraWidgetCount;
|
||||
|
||||
@@ -67,13 +65,10 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
|
||||
e -> addWidget());
|
||||
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(),
|
||||
e -> grid.resetLayout());
|
||||
|
||||
HorizontalLayout toolbar = new HorizontalLayout(add, remove, reset, status);
|
||||
HorizontalLayout toolbar = new HorizontalLayout(add, reset, status);
|
||||
toolbar.setPadding(false);
|
||||
toolbar.setWidthFull();
|
||||
toolbar.setAlignItems(Alignment.CENTER);
|
||||
@@ -83,21 +78,13 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
|
||||
private void addWidget() {
|
||||
// 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.
|
||||
// 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++;
|
||||
String title = getTranslation("gridstack.widget", extraWidgetCount);
|
||||
GridStackItem item = new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
|
||||
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--;
|
||||
grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
|
||||
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
|
||||
}
|
||||
|
||||
private Component lineChart() {
|
||||
|
||||
@@ -176,9 +176,45 @@ vaadin-app-layout::part(content) {
|
||||
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) {
|
||||
.dialect-drag-handle {
|
||||
.dialect-drag-handle,
|
||||
.dialect-close-button {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,15 +24,15 @@ card.registration=Registrierung
|
||||
card.employees=Mitarbeiter
|
||||
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.removeWidget=Letztes entfernen
|
||||
gridstack.reset=Layout zurücksetzen
|
||||
gridstack.status=Layout geändert – {0} Widgets
|
||||
gridstack.statusInitial=Layout unverändert
|
||||
gridstack.widget=Widget {0}
|
||||
gridstack.widgetText=Frei platzierbare Karte.
|
||||
gridstack.dragHandle=Verschieben
|
||||
gridstack.close=Schließen
|
||||
|
||||
chart.revenueSeries=Umsatz 2026
|
||||
chart.pointClick=Serie {0}, Punkt {1}
|
||||
|
||||
@@ -24,15 +24,15 @@ card.registration=Registration
|
||||
card.employees=Employees
|
||||
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.removeWidget=Remove last
|
||||
gridstack.reset=Reset layout
|
||||
gridstack.status=Layout changed – {0} widgets
|
||||
gridstack.statusInitial=Layout unchanged
|
||||
gridstack.widget=Widget {0}
|
||||
gridstack.widgetText=Freely placeable card.
|
||||
gridstack.dragHandle=Move
|
||||
gridstack.close=Close
|
||||
|
||||
chart.revenueSeries=Revenue 2026
|
||||
chart.pointClick=Series {0}, point {1}
|
||||
|
||||
@@ -24,15 +24,15 @@ card.registration=Registro
|
||||
card.employees=Empleados
|
||||
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.removeWidget=Quitar el último
|
||||
gridstack.reset=Restablecer diseño
|
||||
gridstack.status=Diseño modificado – {0} widgets
|
||||
gridstack.statusInitial=Diseño sin cambios
|
||||
gridstack.widget=Widget {0}
|
||||
gridstack.widgetText=Tarjeta de colocación libre.
|
||||
gridstack.dragHandle=Mover
|
||||
gridstack.close=Cerrar
|
||||
|
||||
chart.revenueSeries=Ingresos 2026
|
||||
chart.pointClick=Serie {0}, punto {1}
|
||||
|
||||
@@ -5,13 +5,18 @@ import com.example.components.GridStackItem;
|
||||
import com.example.components.GridStackLayout;
|
||||
import com.vaadin.browserless.SpringBrowserlessTest;
|
||||
import com.vaadin.browserless.ViewPackages;
|
||||
import com.vaadin.browserless.internal.ElementUtilsKt;
|
||||
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.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.assertTrue;
|
||||
|
||||
@@ -32,7 +37,7 @@ class GridStackViewTest extends SpringBrowserlessTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAndRemoveWidget_changesItemCount() {
|
||||
void addAndCloseWidget_changesItemCount() {
|
||||
navigate(GridStackView.class);
|
||||
|
||||
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||
@@ -41,18 +46,59 @@ class GridStackViewTest extends SpringBrowserlessTest {
|
||||
button("gridstack.addWidget").click();
|
||||
assertEquals(initial + 1, grid.getLayout().size());
|
||||
|
||||
button("gridstack.removeWidget").click();
|
||||
clickCloseButton($view(GridStackItem.class).last());
|
||||
assertEquals(initial, grid.getLayout().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeWidget_onDefaultLayout_keepsStaticWidgets() {
|
||||
void closeButton_removesTheClickedWidgetOnly() {
|
||||
navigate(GridStackView.class);
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user