permits EasyGridComposite.IEasyGridDelegate {
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java
index e1fabd2..0b87303 100644
--- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java
@@ -22,6 +22,8 @@
/**
* Unchecked wrapper for {@link ReflectiveOperationException}, used to propagate reflective errors
* without requiring callers to declare or catch checked exceptions.
+ *
+ * @author Javier Godoy / Flowing Code
*/
public class RuntimeReflectiveOperationException extends RuntimeException {
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/AbstractContextMenuRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/AbstractContextMenuRowActionsRenderer.java
new file mode 100644
index 0000000..f0cdb02
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/AbstractContextMenuRowActionsRenderer.java
@@ -0,0 +1,113 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.grid.contextmenu.GridContextMenu;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.NonNull;
+
+/**
+ * Base class for {@link RowActionsRenderer} implementations that present row actions through a
+ * {@link GridContextMenu}. Items are rebuilt dynamically for each row via
+ * {@link GridContextMenu#setDynamicContentHandler}, so visibility, enabled state, and labels are
+ * evaluated per-item at open time.
+ *
+ * Subclasses control how the menu is created and triggered by overriding
+ * {@link #createContextMenu()} and may host the actions in a {@link Grid.Column} by overriding
+ * {@link #getColumn()}.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+abstract class AbstractContextMenuRowActionsRenderer implements RowActionsRenderer {
+
+ /** The grid this renderer decorates. */
+ protected final Grid grid;
+
+ private GridContextMenu contextMenu;
+
+ private List> currentActions = List.of();
+
+ AbstractContextMenuRowActionsRenderer(@NonNull Grid grid) {
+ this.grid = grid;
+ }
+
+ @Override
+ public void update(List> actions) {
+ // action snapshot read by the dynamic content handler on each open
+ currentActions = new ArrayList<>(actions);
+
+ if (contextMenu == null) {
+ var menu = contextMenu = createContextMenu();
+ menu.setDynamicContentHandler(item -> {
+ if (item == null) {
+ return false;
+ }
+ menu.removeAll();
+ for (EasyRowAction action : currentActions) {
+ if (action.isVisible(item)) {
+ String label = action.getLabel(item);
+ var icon = action.getIcon(item);
+ var menuItem = (label != null)
+ ? menu.addItem(label, e -> action.execute(item))
+ : menu.addItem(icon, e -> action.execute(item));
+ menuItem.setEnabled(action.isEnabled(item));
+ if (label != null && icon != null) {
+ menuItem.addComponentAsFirst(icon);
+ }
+ }
+ }
+ return !menu.getItems().isEmpty();
+ });
+ }
+ }
+
+ /**
+ * Creates the {@link GridContextMenu} that backs this renderer. Invoked once, on the first
+ * {@link #update(List)} call, before the dynamic content handler is installed. Subclasses may
+ * override it to customize how the menu is triggered (for example, to suppress the default
+ * right-click behavior). The default implementation registers a new context menu on the grid via
+ * {@link Grid#addContextMenu()}.
+ *
+ * @return the context menu that backs this renderer
+ */
+ protected GridContextMenu createContextMenu() {
+ return grid.addContextMenu();
+ }
+
+ @Override
+ public Grid.Column getColumn() {
+ return null;
+ }
+
+ @Override
+ public void remove() {
+ if (contextMenu != null) {
+ contextMenu.setTarget(null);
+ contextMenu.removeFromParent();
+ contextMenu = null;
+ }
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/Constant.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/Constant.java
new file mode 100644
index 0000000..32138ba
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/Constant.java
@@ -0,0 +1,59 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.function.ValueProvider;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * A constant-valued provider that always returns the same value.
+ *
+ * @param the source type
+ * @param the value type
+ * @author Javier Godoy / Flowing Code
+ */
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+@SuppressWarnings("serial")
+final class Constant implements ValueProvider {
+
+ @Getter
+ private final V value;
+
+ public static Constant of(V value) {
+ return new Constant<>(value);
+ }
+
+ public static Constant ofNullable(V value) {
+ return value == null ? null : of(value);
+ }
+
+ @Override
+ public V apply(T source) {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return "CONSTANT["+value+"]";
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/ContextMenuRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/ContextMenuRowActionsRenderer.java
new file mode 100644
index 0000000..f132385
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/ContextMenuRowActionsRenderer.java
@@ -0,0 +1,42 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.grid.contextmenu.GridContextMenu;
+import lombok.NonNull;
+
+/**
+ * A {@link RowActionsRenderer} that presents row actions as a right-click context menu using
+ * {@link GridContextMenu}. The menu opens on the grid's default right-click gesture. This renderer
+ * does not create a {@link Grid.Column}; {@link #getColumn()} always returns {@code null}.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+final class ContextMenuRowActionsRenderer extends AbstractContextMenuRowActionsRenderer {
+
+ ContextMenuRowActionsRenderer(@NonNull Grid grid) {
+ super(grid);
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/DropdownMenuRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/DropdownMenuRowActionsRenderer.java
new file mode 100644
index 0000000..8c06deb
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/DropdownMenuRowActionsRenderer.java
@@ -0,0 +1,110 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.grid.contextmenu.GridContextMenu;
+import com.vaadin.flow.component.icon.Icon;
+import com.vaadin.flow.component.icon.VaadinIcon;
+import com.vaadin.flow.function.ValueProvider;
+import java.util.List;
+import lombok.NonNull;
+
+/**
+ * A {@link RowActionsRenderer} that presents row actions through an overflow ("⋮") menu. A
+ * dedicated {@link Grid.Column} hosts a trigger button in each row; clicking it opens a
+ * {@link GridContextMenu} whose items are rebuilt dynamically per row. The default right-click
+ * gesture is suppressed so the menu opens only from the trigger button. {@link #getColumn()}
+ * returns the trigger column.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+final class DropdownMenuRowActionsRenderer extends AbstractContextMenuRowActionsRenderer {
+
+ private Grid.Column column;
+
+ DropdownMenuRowActionsRenderer(@NonNull Grid grid) {
+ super(grid);
+ }
+
+ @Override
+ public void update(List> actions) {
+ if (column == null) {
+ var builder = LitRendererBuilder.staticOnly();
+ ValueProvider dots = Constant.of(VaadinIcon.ELLIPSIS_DOTS_V.create());
+ // The trigger button must open the GridContextMenu programmatically, but GridContextMenu
+ // exposes no public API to open it at a given pointer event. Re-route the click into the
+ // connector's own open path ($contextMenuTargetConnector.openOnHandler), after clearing
+ // preventContextMenu so the open is allowed. This relies on Vaadin connector internals;
+ // validated against Vaadin 24.10.x and 25.1.x and covered end-to-end by
+ // EasyRowActionIT.testDropdown, which fails loudly if the connector contract changes.
+ String handler = """
+ ev=>{const grid = ev.composedPath()
+ .find(el => el.matches?.('vaadin-grid-cell-content')).parentElement
+ grid.preventContextMenu=false;
+ grid.$contextMenuTargetConnector.openOnHandler.call(grid,ev);
+ }
+ """;
+ var button = new EasyRowAction(null, null, dots, handler);
+ button.updateRenderer(builder);
+ column = grid.addColumn(builder.build());
+ column.setAutoWidth(true);
+ column.setFlexGrow(0);
+ }
+
+ super.update(actions);
+ }
+
+ @Override
+ protected GridContextMenu createContextMenu() {
+ var menu = super.createContextMenu();
+ // DROPDOWN opens only from the trigger button, never from a row right-click, so drop the
+ // connector's default contextmenu listener. The removal is deferred with setTimeout on
+ // purpose: createContextMenu() runs in the same round-trip that wires up that listener, so a
+ // synchronous removeListener() would run before the listener exists and remove nothing. The
+ // macrotask delay guarantees the connector has finished init first. Same internal-API caveat
+ // and version validation as the trigger handler; covered by
+ // EasyRowActionIT.testDropdownSuppressesContextMenu.
+ grid.getElement().executeJs("""
+ setTimeout(()=>this.$contextMenuTargetConnector.removeListener());
+ """);
+ return menu;
+ }
+
+ @Override
+ public Grid.Column getColumn() {
+ return column;
+ }
+
+ @Override
+ public void remove() {
+ // Tear down the trigger column in addition to the backing context menu, so switching away from
+ // this renderer (or removing it) does not leave an orphaned column behind.
+ super.remove();
+ if (column != null) {
+ grid.removeColumn(column);
+ column = null;
+ }
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowAction.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowAction.java
new file mode 100644
index 0000000..4f872e0
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowAction.java
@@ -0,0 +1,366 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.flowingcode.vaadin.addons.easygrid.RuntimeReflectiveOperationException;
+import com.vaadin.flow.component.ComponentEventListener;
+import com.vaadin.flow.component.HasStyle;
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
+import com.vaadin.flow.component.icon.AbstractIcon;
+import com.vaadin.flow.component.shared.HasThemeVariant;
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.function.SerializableConsumer;
+import com.vaadin.flow.function.SerializablePredicate;
+import com.vaadin.flow.function.SerializableSupplier;
+import com.vaadin.flow.function.ValueProvider;
+import java.io.Serializable;
+import java.lang.reflect.Method;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Represents a row action registered on an {@code EasyGrid}. Row actions are rendered either as
+ * inline buttons in a dedicated actions column or as items in a menu, depending on the grid's
+ * {@link RowActionsStyle}.
+ *
+ * Use the fluent methods to configure conditional visibility, conditional enablement, tooltips,
+ * and optional confirmation dialogs before the action is executed. The rendered button can also be
+ * styled and themed through the inherited {@link HasStyle} and {@link HasThemeVariant} methods.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+public final class EasyRowAction
+ implements Serializable, HasStyle, HasThemeVariant {
+
+ // ConfirmDialog.addOpenedChangeListener was introduced in Vaadin 25; it does not exist in Vaadin 24.
+ // Resolved once at class load: non-null means Vaadin 25 (use the Java listener); null means
+ // Vaadin 24 (fall back to the DOM opened-changed event).
+ private static final Method ADD_OPENED_CHANGE_LISTENER;
+ static {
+ Method addListener = null;
+ try {
+ addListener = ConfirmDialog.class.getMethod("addOpenedChangeListener", ComponentEventListener.class);
+ } catch (NoSuchMethodException ignored) {}
+ ADD_OPENED_CHANGE_LISTENER = addListener;
+ }
+
+ private RowActionsManager manager;
+
+ private final ValueProvider labelProvider;
+ private final ValueProvider> iconProvider;
+ private final SerializableConsumer actionHandler;
+
+ // Icon fields copied explicitly (with their known attribute/property dispatch) so they take
+ // precedence over, and are excluded from, the generic attribute/property copy. The "." prefix
+ // marks a property; the rest are attributes.
+ //
+ // Keep in sync with the well-known icon fields recognized by fc-icon.ts (see FcIcon.render).
+ // The "size" attribute is listed here for explicit dispatch only: it is intentionally absent
+ // from the fc-icon render guard, since "size" alone does not identify an icon source.
+ private static final String[] PRECEDENCE_ICON_NAMES =
+ {"icon", "src", "size", ".symbol", ".ligature", ".char", ".fontFamily", ".iconClass"};
+
+ /**
+ * Backing element for this action. It is never attached to the DOM; it carries the attributes,
+ * CSS classes/styles ({@link HasStyle}), and theme variants ({@link HasThemeVariant}) that are
+ * forwarded onto the rendered {@code } when the actions column is built.
+ */
+ @Getter
+ private final Element element = new Element("easy-row-action");
+
+ EasyRowAction(RowActionsManager manager,
+ ValueProvider labelProvider,
+ ValueProvider> iconProvider,
+ SerializableConsumer actionHandler) {
+ if (labelProvider == null && iconProvider == null) {
+ throw new IllegalArgumentException("At least one of label or icon must be non-null");
+ }
+ this.manager = manager;
+ this.labelProvider = labelProvider;
+ this.iconProvider = iconProvider;
+ this.actionHandler = actionHandler;
+ }
+
+ EasyRowAction(RowActionsManager manager,
+ ValueProvider labelProvider,
+ ValueProvider> iconProvider,
+ String eventHandler) {
+ this(manager, labelProvider, iconProvider, new ClientSideEventHandler(eventHandler));
+ }
+
+ @RequiredArgsConstructor
+ private final static class ClientSideEventHandler implements SerializableConsumer {
+
+ @NonNull
+ final String eventHandler;
+
+ @Override
+ public void accept(T t) {
+ throw new UnsupportedOperationException();
+ }
+
+ }
+
+ private SerializablePredicate visibleWhen;
+ private SerializablePredicate enabledWhen;
+ private ValueProvider tooltipProvider;
+ private SerializableSupplier confirmDialogSupplier;
+ private transient boolean confirmPending;
+
+ private void refresh() {
+ if (manager != null) {
+ manager.refresh();
+ }
+ }
+
+ /**
+ * Sets a predicate that controls whether this action is visible for a given row item.
+ *
+ * @param predicate a predicate evaluated for each row item; the action is visible when it returns
+ * {@code true}.
+ * @return this action, for method chaining
+ */
+ public EasyRowAction visibleWhen(SerializablePredicate predicate) {
+ this.visibleWhen = predicate;
+ refresh();
+ return this;
+ }
+
+ /**
+ * Sets a predicate that controls whether this action is enabled for a given row item.
+ *
+ * @param predicate a predicate evaluated for each row item; the action is enabled when it returns
+ * {@code true}.
+ * @return this action, for method chaining
+ */
+ public EasyRowAction enabledWhen(SerializablePredicate predicate) {
+ this.enabledWhen = predicate;
+ refresh();
+ return this;
+ }
+
+ /**
+ * Sets a static tooltip for this action.
+ *
+ * @param tooltip the tooltip text
+ * @return this action, for method chaining
+ */
+ public EasyRowAction tooltip(String tooltip) {
+ this.tooltipProvider = Constant.of(tooltip);
+ refresh();
+ return this;
+ }
+
+ /**
+ * Sets a dynamic tooltip for this action, computed from the row item.
+ *
+ * @param tooltipProvider a function that returns the tooltip text for a given row item
+ * @return this action, for method chaining
+ */
+ public EasyRowAction tooltip(ValueProvider tooltipProvider) {
+ this.tooltipProvider = tooltipProvider;
+ refresh();
+ return this;
+ }
+
+ /**
+ * Configures a confirmation dialog with a message (no title) before the action is executed.
+ *
+ * @param message the confirmation message
+ * @return this action, for method chaining
+ */
+ public EasyRowAction withConfirmation(String message) {
+ return withConfirmation(null, message);
+ }
+
+ /**
+ * Configures a confirmation dialog with both a title and a message before the action is executed.
+ *
+ * @param title the dialog title
+ * @param message the confirmation message
+ * @return this action, for method chaining
+ */
+ public EasyRowAction withConfirmation(String title, String message) {
+ return withConfirmation(title, message, "Ok", "Cancel");
+ }
+
+ private EasyRowAction withConfirmation(String title, String message, String confirmText,
+ String cancelText) {
+ confirmDialogSupplier = () -> {
+ var dialog = new ConfirmDialog();
+ dialog.setHeader(title);
+ dialog.setText(message);
+ dialog.setConfirmText(confirmText);
+ dialog.setCancelable(true);
+ dialog.setCancelText(cancelText);
+ return dialog;
+ };
+ // Render-neutral (the dialog is built at click time), but refreshed so every fluent setter
+ // behaves uniformly. The scheduled rebuild is coalesced with any other pending update.
+ refresh();
+ return this;
+ }
+
+ /**
+ * Removes this action from the grid's actions column. The column is re-rendered on the next
+ * {@code beforeClientResponse} cycle; if no actions remain the column is hidden. Calling
+ * {@code remove()} on an action that was never registered, or that has already been removed,
+ * is a no-op.
+ */
+ public void remove() {
+ if (manager != null) {
+ var manager = this.manager;
+ this.manager = null;
+ manager.removeRowAction(this);
+ }
+ }
+
+ boolean isVisible(T item) {
+ return visibleWhen == null || visibleWhen.test(item);
+ }
+
+ boolean isEnabled(T item) {
+ return enabledWhen == null || enabledWhen.test(item);
+ }
+
+ String getLabel(T item) {
+ return labelProvider != null ? labelProvider.apply(item) : null;
+ }
+
+ AbstractIcon> getIcon(T item) {
+ return iconProvider != null ? iconProvider.apply(item) : null;
+ }
+
+ void execute(T item) {
+ // Server-side guard: reject the click if the item no longer satisfies visibleWhen/enabledWhen.
+ // The client-side conditional rendering and ?disabled binding prevent most clicks, but this
+ // closes the gap for race conditions and devtools manipulation: the per-row server function is
+ // registered for every item, so an invisible/disabled action remains reachable via a crafted RPC.
+ if (!isVisible(item) || !isEnabled(item)) {
+ return;
+ }
+ if (confirmDialogSupplier != null) {
+ // Prevent multiple dialogs from stacking on rapid clicks.
+ if (confirmPending) {
+ return;
+ }
+ confirmPending = true;
+ ConfirmDialog dialog = confirmDialogSupplier.get();
+ dialog.addConfirmListener(e -> {
+ if (isVisible(item) && isEnabled(item)) {
+ actionHandler.accept(item);
+ }
+ });
+ // Reset on any close path: confirm, cancel, or programmatic dialog.close()
+ if (ADD_OPENED_CHANGE_LISTENER != null) {
+ @SuppressWarnings({"rawtypes"})
+ ComponentEventListener l = e -> {
+ if (!dialog.isOpened()) {
+ confirmPending = false;
+ }
+ };
+ try {
+ ADD_OPENED_CHANGE_LISTENER.invoke(dialog, l);
+ } catch (ReflectiveOperationException ex) {
+ throw new RuntimeReflectiveOperationException(ex);
+ }
+ } else {
+ dialog.getElement().addEventListener("opened-changed", e -> confirmPending = false)
+ .setFilter("event.detail.value === false");
+ }
+ dialog.open();
+ } else {
+ actionHandler.accept(item);
+ }
+ }
+
+ void updateRenderer(LitRendererBuilder renderer) {
+
+ // Wrap the entire button in a visibility guard; renders nothing when visibleWhen returns false
+ renderer.withCondition(visibleWhen, () -> {
+
+ // Open the element that represents this action in the row
+ renderer.tag("vaadin-button", () -> {
+ if (enabledWhen != null) {
+ // Bind disabled to the inverse of enabledWhen so the button grays out when the predicate is false
+ renderer.bindBoolean("disabled", t -> !enabledWhen.test(t));
+ }
+
+ // Forward all attributes/properties set on this action's element (e.g. CSS classes) except the two handled below.
+ // Only exclude "title" if there's a tooltip provider; otherwise preserve manually-set title attributes.
+ String[] excluded = tooltipProvider != null ? new String[]{"theme", "title"} : new String[]{"theme"};
+ renderer.copyAllAttributesAndPropertiesExcept(this, excluded);
+ // Bind the title attribute per-item so tooltip text can vary by row (only if a provider is set)
+ if (tooltipProvider != null) {
+ renderer.bind("title", tooltipProvider);
+ }
+ // Set the theme attribute statically; getTheme() appends "icon" when the button is icon-only
+ renderer.set("theme", getTheme());
+
+ if (actionHandler instanceof ClientSideEventHandler c) {
+ renderer.event("click", c.eventHandler);
+ } else if (actionHandler != null) {
+ // Wire the DOM click event to the server-side function
+ int fn = renderer.withFunction((item, args) -> execute(item));
+ renderer.event("click", fn);
+ }
+
+ if (iconProvider instanceof Constant) {
+ var icon = iconProvider.apply(null);
+ if (icon != null) {
+ renderer.tag("vaadin-icon", () -> {
+ renderer.copyAttributes(icon, PRECEDENCE_ICON_NAMES);
+ renderer.copyAllAttributesAndPropertiesExcept(icon, PRECEDENCE_ICON_NAMES);
+ });
+ }
+ } else if (iconProvider != null) {
+ // Open an child to render the icon; the icon element is evaluated per item
+ renderer.tag("fc-icon", () -> {
+ // Spread the icon's relevant attributes/properties (src, ligature, etc.) onto the element
+ renderer.spreadAllAttributesAndProperties(iconProvider);
+ });
+ }
+
+ // Render the label text as button content; no-op when labelProvider is null (icon-only button)
+ renderer.addContent(labelProvider);
+ });
+ });
+ }
+
+ /**
+ * Returns the {@code theme} attribute value for this action's {@code }, combining
+ * any user-set theme variants (e.g. {@code "primary"}) with {@code "icon"} when the button is
+ * icon-only (an icon is configured and no label provider was set). Returns {@code null} when no
+ * theme variant applies.
+ */
+ String getTheme() {
+ String theme = getElement().getAttribute("theme");
+ if (iconProvider != null && labelProvider == null) {
+ theme = theme == null || theme.isEmpty() ? "icon" : theme + " icon";
+ }
+ return theme == null || theme.isEmpty() ? null : theme;
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/HasRowActions.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/HasRowActions.java
new file mode 100644
index 0000000..c76df2c
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/HasRowActions.java
@@ -0,0 +1,223 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
+import com.vaadin.flow.component.dependency.CssImport;
+import com.vaadin.flow.component.dependency.JsModule;
+import com.vaadin.flow.component.dependency.Uses;
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.icon.AbstractIcon;
+import com.vaadin.flow.component.icon.Icon;
+import com.vaadin.flow.component.icon.IconFactory;
+import com.vaadin.flow.function.SerializableConsumer;
+import com.vaadin.flow.function.ValueProvider;
+import lombok.NonNull;
+
+/**
+ * Mixin interface that adds a configurable row-actions column to a {@link Grid}. Implemented by
+ * {@code EasyGrid}, it provides fluent {@code default} methods to register per-row actions (the
+ * {@code addRowAction} family), choose how they are presented
+ * ({@link #setRowActionsStyle(RowActionsStyle)}), and set default theme variants
+ * ({@link #setDefaultRowActionVariants(ButtonVariant...)}). Every method delegates to the backing
+ * {@link RowActionsManager} returned by {@link #getRowActionsManager()}.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@CssImport(value = "./fc-dynamic-buttons.css")
+@JsModule("./fc-icon.ts")
+@Uses(Button.class)
+@Uses(ConfirmDialog.class)
+public interface HasRowActions {
+
+ /**
+ * Returns the {@code RowActionsManager} that backs this grid's actions column. Every other method
+ * of this interface is a {@code default} method that delegates to it.
+ *
+ * @return the row actions manager, never {@code null}
+ * @apiNote This is a Service Provider Interface (SPI) method: it exists so that classes which
+ * implement {@code HasRowActions} (such as {@code EasyGrid}) can supply the
+ * backing manager. Application code should not call it directly; configure row actions
+ * through the grid's own methods (e.g. {@code addRowAction(...)},
+ * {@code setRowActionsStyle(...)}).
+ */
+ RowActionsManager getRowActionsManager();
+
+ /**
+ * Adds a label-only row action that invokes {@code handler} when clicked.
+ *
+ * @param label the button label
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default EasyRowAction addRowAction(@NonNull String label,
+ @NonNull SerializableConsumer handler) {
+ return addRowAction(label, (ValueProvider) null, handler);
+ }
+
+ /**
+ * Adds a row action with a static label and icon that invokes {@code handler} when clicked.
+ * Either {@code label} or {@code icon} may be {@code null}, but not both.
+ *
+ * @param label the button label, or {@code null} for icon-only
+ * @param icon the button icon, or {@code null} for label-only
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default EasyRowAction addRowAction(
+ String label,
+ Icon icon,
+ @NonNull SerializableConsumer handler) {
+ return addRowAction(label, Constant.ofNullable(icon), handler);
+ }
+
+ /**
+ * Adds an icon-only row action that invokes {@code handler} when clicked.
+ *
+ * @param icon the button icon
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default EasyRowAction addRowAction(
+ @NonNull Icon icon,
+ @NonNull SerializableConsumer handler) {
+ return addRowAction(null, icon, handler);
+ }
+
+ /**
+ * Adds a row action with a static label and an icon created from {@code iconFactory} that
+ * invokes {@code handler} when clicked. {@code label} may be {@code null} for icon-only.
+ *
+ * @param label the button label, or {@code null} for icon-only
+ * @param iconFactory factory used to create the button icon
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default EasyRowAction addRowAction(
+ String label,
+ @NonNull IconFactory iconFactory,
+ @NonNull SerializableConsumer handler) {
+ return addRowAction(label, iconFactory.create(), handler);
+ }
+
+ /**
+ * Adds an icon-only row action whose icon is created from {@code iconFactory} that invokes
+ * {@code handler} when clicked.
+ *
+ * @param iconFactory factory used to create the button icon
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default EasyRowAction addRowAction(
+ @NonNull IconFactory iconFactory,
+ @NonNull SerializableConsumer handler) {
+ return addRowAction(null, iconFactory.create(), handler);
+ }
+
+ /**
+ * Adds an icon-only row action whose icon is computed per row from {@code iconProvider} that
+ * invokes {@code handler} when clicked.
+ *
+ * @param the icon type
+ * @param iconProvider per-row provider for the button icon
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default > EasyRowAction addRowAction(
+ @NonNull ValueProvider iconProvider,
+ @NonNull SerializableConsumer handler) {
+ return addRowAction(null, iconProvider, handler);
+ }
+
+ /**
+ * Adds a row action with a static label and a per-row icon that invokes {@code handler} when
+ * clicked. Either {@code label} or {@code iconProvider} may be {@code null}, but not both.
+ *
+ * @param the icon type
+ * @param label the button label, or {@code null} for icon-only
+ * @param iconProvider per-row provider for the button icon, or {@code null} for label-only
+ * @param handler the action to execute when clicked
+ * @return the registered action
+ */
+ default > EasyRowAction addRowAction(
+ String label,
+ ValueProvider iconProvider,
+ @NonNull SerializableConsumer handler) {
+ return getRowActionsManager().addRowAction(Constant.ofNullable(label), iconProvider, handler);
+ }
+
+ /**
+ * Sets the style used to present row actions: inline buttons, a dropdown overflow menu, or a
+ * right-click context menu.
+ *
+ * @param style the row actions style
+ */
+ default void setRowActionsStyle(@NonNull RowActionsStyle style) {
+ getRowActionsManager().setRowActionsStyle(style);
+ }
+
+ /**
+ * Replaces the active row-actions renderer. The current renderer is cleaned up before the new
+ * one is installed, and a rebuild is scheduled for the next {@code beforeClientResponse} cycle.
+ *
+ * @param renderer the new renderer to use
+ */
+ default void setRowActionsRenderer(RowActionsRenderer renderer) {
+ getRowActionsManager().setRenderer(renderer);
+ }
+
+ /**
+ * Returns the {@code Grid.Column} that hosts the actions, or {@code null} if the active renderer
+ * does not use a column (e.g. a context-menu renderer). For column-based renderers the column is
+ * created on demand, hidden when no actions are registered, and made visible automatically when
+ * the first action is added.
+ *
+ * @return the actions column, or {@code null} if the active renderer does not use a column
+ */
+ default Grid.Column getActionsColumn() {
+ return getRowActionsManager().getActionsColumn();
+ }
+
+ /**
+ * Sets the theme variants that will be applied upon creation to all row actions added after this
+ * call. Pass no arguments (or {@code null}) to clear the default variants.
+ *
+ * @param variants the variants to apply to each new action
+ */
+ default void setDefaultRowActionVariants(ButtonVariant... variants) {
+ getRowActionsManager().setDefaultRowActionVariants(variants);
+ }
+
+ /**
+ * Schedules a renderer rebuild on the next {@code beforeClientResponse} cycle. The fluent
+ * configuration methods of {@link EasyRowAction} ({@code visibleWhen}, {@code enabledWhen},
+ * {@code tooltip}, {@code withConfirmation}) schedule this automatically, so an explicit call is
+ * only needed after changing an action's styling or theme variants (e.g. {@code addClassName},
+ * {@code getStyle()}, {@code addThemeVariants}), which are not applied automatically. Any
+ * previously scheduled rebuild is cancelled and replaced.
+ */
+ default void refreshRowActions() {
+ getRowActionsManager().refresh();
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilder.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilder.java
new file mode 100644
index 0000000..4771942
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilder.java
@@ -0,0 +1,535 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.flowingcode.vaadin.jsonmigration.JsonMigration;
+import com.flowingcode.vaadin.jsonmigration.JsonSerializer;
+import com.flowingcode.vaadin.jsonmigration.LitRendererMigrationExtension;
+import com.vaadin.flow.component.HasElement;
+import com.vaadin.flow.data.renderer.LitRenderer;
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.function.SerializableBiConsumer;
+import com.vaadin.flow.function.SerializablePredicate;
+import com.vaadin.flow.function.ValueProvider;
+import elemental.json.Json;
+import elemental.json.JsonArray;
+import elemental.json.JsonObject;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import lombok.NonNull;
+import lombok.experimental.ExtensionMethod;
+
+/**
+ * Builds the Lit template and backing {@link LitRenderer} for an {@code EasyGrid} row-actions
+ * column. Callers open elements with {@link #tag(String, Runnable)} and add attribute/property
+ * bindings, content, and per-row server functions; all per-row values are exposed to the template
+ * through a single {@code item.} object, addressed by index.
+ *
+ * The builder is single-use: after {@link #build()} (or {@link #getTemplate()}) it is closed and
+ * further mutation throws {@link IllegalStateException}. When at least one per-row value is
+ * registered, the whole template is wrapped in a presence guard so it renders nothing until the
+ * backing property object is populated on the client.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@ExtensionMethod(value = LitRendererMigrationExtension.class, suppressBaseMethods = true)
+final class LitRendererBuilder {
+
+ private static final Pattern PROPERTY_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9]*");
+ private static final Pattern TAG_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9-]*");
+
+ private final String property;
+ private final StringBuilder template = new StringBuilder();
+ private final List> functionHandlers = new ArrayList<>();
+ private final List> properties = new ArrayList<>();
+ private boolean tagOpen = false;
+ private boolean closed = false;
+
+ /**
+ * Creates a builder for a static-only template, one that emits no per-row values and so
+ * needs no backing {@code item.} object. Only build-time literals are permitted (e.g.
+ * {@link #set}, or {@link #bind}/{@link #addContent} with a {@link Constant}); any operation that
+ * would register a per-row value ({@code bind} with a dynamic provider, {@link #withCondition},
+ * {@link #bindBoolean}, {@link #spreadAllAttributesAndProperties}, {@link #withFunction}, or the
+ * presence guard added by {@link #build()}) throws {@link IllegalStateException}.
+ *
+ * For a template that binds per-row values, use {@link #LitRendererBuilder(String)} instead.
+ *
+ * @param the grid bean type
+ * @return a builder that accepts only build-time literals (no {@code item.} object)
+ */
+ public static LitRendererBuilder staticOnly() {
+ return new LitRendererBuilder<>();
+ }
+
+ private LitRendererBuilder() {
+ this.property = null;
+ }
+
+ /**
+ * Creates a builder for a template that binds per-row values through a backing
+ * {@code item.} object addressed by index. For a template that emits only build-time
+ * literals, use {@link #staticOnly()} instead.
+ *
+ * @param property the name of the backing per-row property object; must be an alphanumeric
+ * identifier starting with a letter
+ * @throws IllegalArgumentException if {@code property} is not a valid identifier
+ */
+ public LitRendererBuilder(@NonNull String property) {
+ if (!PROPERTY_PATTERN.matcher(property).matches()) {
+ throw new IllegalArgumentException(
+ "Property must be an alphanumeric identifier starting with a letter: " + property);
+ }
+ this.property = property;
+ }
+
+ private String getFunctionName(int index) {
+ // LitRenderer.withFunction requires alphanumeric names with no underscores.
+ requireProperty();
+ return property + "Handler" + index;
+ }
+
+ private void close() {
+ if (!closed) {
+ if (!properties.isEmpty()) {
+ requireProperty();
+ template.insert(0, "${item.%s ? html`".formatted(property));
+ template.append("` : undefined}");
+ }
+ closed = true;
+ }
+ }
+
+ private void requireProperty() {
+ if (property == null) {
+ throw new IllegalStateException(
+ "Property name is required to register per-row values");
+ }
+ }
+
+ private void requireNotClosed() {
+ if (closed) {
+ throw new IllegalStateException("Builder has already been closed");
+ }
+ }
+
+ /** For testing only. */
+ String getTemplate() {
+ close();
+ return template.toString();
+ }
+
+ /** Finalizes the template and builds the {@code LitRenderer}. */
+ public LitRenderer build() {
+ close();
+
+ LitRenderer renderer = LitRenderer.of(template.toString());
+ if (!properties.isEmpty()) {
+ requireProperty();
+ int n = properties.size();
+ String[] keys = new String[n];
+ for (int i = 0; i < n; i++) {
+ keys[i] = Integer.toString(i);
+ }
+ @SuppressWarnings("unchecked")
+ ValueProvider[] providers = properties.toArray(new ValueProvider[n]);
+ renderer.withProperty(property, t -> {
+ var obj = Json.createObject();
+ for (int i = 0; i < n; i++) {
+ obj.put(keys[i], JsonSerializer.toJson(providers[i].apply(t)));
+ }
+ return JsonMigration.convertToClientCallableResult(obj);
+ });
+ }
+
+ for (int i = 0; i < functionHandlers.size(); i++) {
+ renderer.withFunction(getFunctionName(i), functionHandlers.get(i));
+ }
+ return renderer;
+ }
+
+
+
+ /**
+ * Opens an element: appends {@code } is emitted later), runs
+ * {@code body}, then closes with {@code }. The body should add
+ * attribute bindings first (via {@link #set}, {@link #bind}, {@link #bindBoolean},
+ * {@link #copyAttributes}, {@link #spreadAllAttributesAndProperties}) and then content (via
+ * nested {@link #tag},
+ * {@link #withCondition}, or {@link #addContent}). The opening {@code >} is
+ * emitted automatically the first time the body adds content, or at body-end for an empty tag.
+ * Tags nest to arbitrary depth.
+ *
+ * @param name the tag name; must match {@code [a-zA-Z][a-zA-Z0-9-]*}
+ * @param body adds the tag's attributes and content
+ * @throws IllegalArgumentException if {@code name} is not a valid tag name
+ */
+ public void tag(String name, Runnable body) {
+ requireNotClosed();
+ if (name == null || !TAG_NAME_PATTERN.matcher(name).matches()) {
+ throw new IllegalArgumentException("Invalid tag name: " + name);
+ }
+ finishOpeningTag();
+ template.append('<').append(name);
+ tagOpen = true;
+ body.run();
+ finishOpeningTag();
+ template.append("").append(name).append('>');
+ }
+
+ /**
+ * Wraps {@code body} in a Lit conditional that renders only when {@code predicate} is
+ * {@code true} for the current row. A {@code null} predicate is treated as always-true and
+ * {@code body} is invoked directly with no surrounding conditional.
+ *
+ * @param predicate evaluated for each row item; the body renders only when it returns
+ * {@code true}, or always when {@code null}
+ * @param body adds the conditionally-rendered content
+ */
+ public void withCondition(SerializablePredicate predicate, Runnable body) {
+ requireNotClosed();
+ if (predicate == null) {
+ body.run();
+ return;
+ }
+ requireProperty();
+ finishOpeningTag();
+ template.append("${item.%s[%s] ? html`".formatted(property, register(predicate::test)));
+ body.run();
+ finishOpeningTag();
+ template.append("` : undefined}");
+ }
+
+ /**
+ * Emits an attribute or property with a literal value. {@code null} is a no-op; otherwise the
+ * value is inlined at build time with prefix-aware dispatch.
+ *
+ * @param name the attribute or property name, optionally with a {@code .} or {@code ?} binding
+ * prefix
+ * @param value the literal value, or {@code null} to emit nothing
+ */
+ public void set(String name, String value) {
+ requireNotClosed();
+ bind(name, Constant.ofNullable(value));
+ }
+
+ /**
+ * Binds an attribute or property to a per-row value. A {@code Constant} value is inlined at
+ * build time; {@code null} is a no-op.
+ *
+ * @param name the attribute or property name, optionally with a {@code .} or {@code ?} binding
+ * prefix
+ * @param value per-row provider for the value, or {@code null} to emit nothing
+ */
+ public void bind(String name, ValueProvider value) {
+ requireNotClosed();
+ if (value == null) {
+ return;
+ }
+ if (value instanceof Constant) {
+ emitLiteral(name, value.apply(null));
+ } else {
+ requireTagOpen();
+ requireProperty();
+ template.append(" %s=${item.%s[%s]}".formatted(name, property, register(value)));
+ }
+ }
+
+ /**
+ * Binds the current tag's content to a per-row value. A {@code Constant} value is inlined at
+ * build time; {@code null} provider is a no-op. The opening tag's {@code >} is closed first if
+ * needed.
+ *
+ * @param value per-row provider for the content, or {@code null} to emit nothing
+ */
+ public void addContent(ValueProvider value) {
+ requireNotClosed();
+ if (value == null) {
+ return;
+ }
+ if (value instanceof Constant) {
+ String str = value.apply(null);
+ if (str == null) {
+ return;
+ }
+ finishOpeningTag();
+ template.append("${`").append(escapeTemplateLiteral(str)).append("`}");
+ } else {
+ requireProperty();
+ finishOpeningTag();
+ template.append("${item.%s[%s]}".formatted(property, register(value)));
+ }
+ }
+
+ /**
+ * Emits a literal attribute, dispatching on the Lit binding prefix in {@code name}:
+ *
+ * - No prefix: emits an HTML attribute {@code name="value"}.
+ * - {@code .} prefix (property binding): emits
.name=${`value`}.
+ * - {@code ?} prefix (boolean attribute binding): emits
?name=${true} unless
+ * {@code value} is {@code "false"}; in that case the attribute is omitted.
+ *
+ * {@code null} values are no-ops.
+ */
+ private void emitLiteral(String name, String value) {
+ if (value == null) {
+ return;
+ }
+ if (name.startsWith("?")) {
+ if ("false".equals(value)) {
+ return;
+ }
+ requireTagOpen();
+ template.append(" %s=${true}".formatted(name));
+ } else if (name.startsWith(".")) {
+ requireTagOpen();
+ template.append(" %s=${`%s`}".formatted(name, escapeTemplateLiteral(value)));
+ } else {
+ requireTagOpen();
+ template.append(" %s=%s".formatted(name, wrapAndEscapeTemplateCharacters(value)));
+ }
+ }
+
+ /**
+ * Emits a Lit event listener binding @eventName=${functionName}. {@code functionName}
+ * must reference a function previously registered via {@link #withFunction}.
+ *
+ * @param eventName the DOM event name (without the {@code @} prefix)
+ * @param functionIndex the index of a function previously registered via {@link #withFunction}
+ */
+ public void event(String eventName, int functionIndex) {
+ requireNotClosed();
+ requireTagOpen();
+ template.append(" @%s=${%s}".formatted(eventName, getFunctionName(functionIndex)));
+ }
+
+ /**
+ * Emits a Lit event listener binding @eventName=${handler} where {@code handler} is
+ * an inline client-side expression rather than a registered function reference.
+ *
+ * @param eventName the DOM event name (without the {@code @} prefix)
+ * @param handler the client-side event handler expression
+ */
+ public void event(String eventName, String handler) {
+ requireNotClosed();
+ requireTagOpen();
+ template.append(" @%s=${%s}".formatted(eventName, handler));
+ }
+
+ /**
+ * Binds a Lit boolean attribute ?name=${...} to a per-row predicate. {@code null} is a
+ * no-op.
+ */
+ public void bindBoolean(String name, SerializablePredicate predicate) {
+ requireNotClosed();
+ if (predicate != null) {
+ requireTagOpen();
+ requireProperty();
+ template.append(" ?%s=${item.%s[%s]}".formatted(name, property, register(predicate::test)));
+ }
+ }
+
+ /**
+ * Snapshots the named attributes from {@code component}'s element and delegates each non-empty
+ * value to {@link #set(String, String)}. Names with {@code .} or {@code ?} prefixes are
+ * read via {@link Element#getProperty(String)} on the stripped name; names with no prefix are
+ * read via {@link Element#getAttribute(String)}. Emission (HTML attribute vs. property vs.
+ * boolean binding) follows {@code set(String, String)}'s dispatch rules.
+ */
+ public void copyAttributes(HasElement component, String... names) {
+ requireNotClosed();
+ requireTagOpen();
+
+ Element element = component.getElement();
+ for (String name : names) {
+ String value = switch (BindingType.of(name)) {
+ case ATTRIBUTE -> element.getAttribute(name);
+ default -> element.getProperty(name.substring(1));
+ };
+ set(name, value);
+ }
+ }
+
+ /**
+ * Snapshots all attributes and properties from {@code component}'s element (excluding names
+ * listed in {@code names}) and delegates each to {@link #set(String, String)}. Properties are
+ * emitted with a {@code .} prefix; attributes are emitted as-is. An empty {@code names} array
+ * copies everything.
+ *
+ * Exclusions are matched against the emitted binding name, prefix included: a plain
+ * name (e.g. {@code "theme"}) excludes only the attribute of that name, while a {@code .}-prefixed
+ * name (e.g. {@code ".theme"}) excludes only the property. To exclude both the attribute and the
+ * property of the same name, list both forms.
+ */
+ public void copyAllAttributesAndPropertiesExcept(HasElement component, String... names) {
+ requireNotClosed();
+ requireTagOpen();
+
+ Element element = component.getElement();
+
+ element.getAttributeNames().filter(excludingAttribute(names)).forEach(name->{
+ set(name, element.getAttribute(name));
+ });
+
+ element.getPropertyNames().filter(excludingProperty(names)).forEach(name->{
+ set("."+name, element.getProperty(name));
+ });
+ }
+
+ private Predicate super String> excludingAttribute(String[] names) {
+ return name -> {
+ for (String n : names) {
+ if (n.equals(name)) {
+ return false;
+ }
+ }
+ return true;
+ };
+ }
+
+ private Predicate super String> excludingProperty(String[] names) {
+ Predicate super String> excludingAttribute = excludingAttribute(names);
+ return name -> excludingAttribute.test("."+name);
+ }
+
+ /**
+ * Binds per-row {@code .attr} and {@code .prop} object properties on the current open tag,
+ * populated from all attributes and properties of the component returned by
+ * {@code componentProvider}. Attributes are collected into a map bound to {@code .attr};
+ * properties are collected into a map bound to {@code .prop}. Empty maps are passed as
+ * {@code null}; a {@code null} component maps to {@code null} for both.
+ *
+ * @param the component type
+ * @param componentProvider provides the source component for each row item
+ */
+ public void spreadAllAttributesAndProperties(
+ ValueProvider componentProvider) {
+ requireNotClosed();
+ requireTagOpen();
+ requireProperty();
+
+ // Evaluate componentProvider once per item across all lambdas.
+ // Cache intentionally shadows the enclosing method's T and C type parameters:
+ // local records are implicitly static and cannot capture method type parameters directly.
+ record Cache(T item, C component) {}
+ Ref> cacheRef = new Ref<>();
+
+ ValueProvider once = item -> {
+ if (cacheRef.value == null || cacheRef.value.item != item) {
+ cacheRef.value = new Cache<>(item, componentProvider.apply(item));
+ }
+ return cacheRef.value.component;
+ };
+
+ int attrIdx = register(item -> {
+ C component = once.apply(item);
+ if (component == null) {
+ return null;
+ }
+ Element el = component.getElement();
+ JsonObject obj = Json.createObject();
+ el.getAttributeNames().forEach(name -> obj.put(name, el.getAttribute(name)));
+ return obj.keys().length == 0 ? null : obj;
+ });
+
+ int propIdx = register(item -> {
+ C component = once.apply(item);
+ if (component == null) {
+ return null;
+ }
+ Element el = component.getElement();
+ JsonObject obj = Json.createObject();
+ el.getPropertyNames().forEach(name -> obj.put(name, el.getProperty(name)));
+ return obj.keys().length == 0 ? null : obj;
+ });
+
+ template.append(" .attr=${item.%s[%d]} .prop=${item.%s[%d]}".formatted(property, attrIdx, property, propIdx));
+ }
+
+ /**
+ * Registers a server-side function handler and returns its index for use with
+ * {@link #event(String, int)}.
+ *
+ * @param handler the function handler invoked when the client fires the event
+ * @return the index of the registered function
+ */
+ public int withFunction(SerializableBiConsumer handler) {
+ requireNotClosed();
+ requireProperty();
+ functionHandlers.add(handler);
+ return functionHandlers.size() - 1;
+ }
+
+ private int register(ValueProvider provider) {
+ int index = properties.size();
+ properties.add(provider);
+ return index;
+ }
+
+ private void finishOpeningTag() {
+ if (tagOpen) {
+ template.append('>');
+ tagOpen = false;
+ }
+ }
+
+ private void requireTagOpen() {
+ if (!tagOpen) {
+ throw new IllegalStateException(
+ "Attribute can only be added inside a start tag, before any content");
+ }
+ }
+
+ private static String wrapAndEscapeTemplateCharacters(String value) {
+ if (value.indexOf('"') < 0 && value.indexOf('`') < 0 && value.indexOf('\\') < 0
+ && !value.contains("${")) {
+ return '"' + value + '"';
+ }
+ return "${`" + escapeTemplateLiteral(value) + "`}";
+ }
+
+ private static String escapeTemplateLiteral(String value) {
+ return value.replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$");
+ }
+
+ @SuppressWarnings("serial")
+ private static final class Ref implements Serializable {
+ transient V value;
+ }
+
+ enum BindingType {
+ ATTRIBUTE, PROPERTY, BOOLEAN;
+
+ static BindingType of(String name) {
+ if (name.startsWith("?")) {
+ return BOOLEAN;
+ }
+ if (name.startsWith(".")) {
+ return PROPERTY;
+ }
+ return ATTRIBUTE;
+ }
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRowActionsRenderer.java
new file mode 100644
index 0000000..82013c4
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRowActionsRenderer.java
@@ -0,0 +1,87 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.grid.Grid;
+import java.util.List;
+import lombok.NonNull;
+
+/**
+ * A {@link RowActionsRenderer} that renders row actions as inline buttons inside a dedicated
+ * {@link Grid.Column}, using a Lit template backed by {@link LitRendererBuilder}. The column is
+ * created the first time {@link #update} is called, and its renderer is replaced on every
+ * subsequent call.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+final class LitRowActionsRenderer implements RowActionsRenderer {
+
+ private static final String GRID_BUTTONS_COUNT = "--grid-buttons-count";
+
+ private final Grid grid;
+ private Grid.Column column;
+ private int propertySequence;
+
+ LitRowActionsRenderer(@NonNull Grid grid) {
+ this.grid = grid;
+ }
+
+ private String nextProperty() {
+ // A fresh property name on each update prevents the client from reusing stale per-row data
+ // bound by the previous renderer. A monotonic counter is collision-free by construction.
+ return "fcRowActions" + propertySequence++;
+ }
+
+ @Override
+ public void update(List> actions) {
+ var builder = new LitRendererBuilder(nextProperty());
+ builder.tag("fc-dynamic-buttons", () -> {
+ actions.forEach(action -> action.updateRenderer(builder));
+ });
+ var litRenderer = builder.build();
+ if (column == null) {
+ column = grid.addColumn(litRenderer);
+ column.setAutoWidth(true);
+ column.setFlexGrow(0);
+ } else {
+ column.setRenderer(litRenderer);
+ grid.getGenericDataView().refreshAll();
+ }
+ grid.getElement().getStyle().set(GRID_BUTTONS_COUNT, Integer.toString(actions.size()));
+ }
+
+ @Override
+ public Grid.Column getColumn() {
+ return column;
+ }
+
+ @Override
+ public void remove() {
+ if (column != null) {
+ grid.removeColumn(column);
+ column = null;
+ }
+ grid.getElement().getStyle().remove(GRID_BUTTONS_COUNT);
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsManager.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsManager.java
new file mode 100644
index 0000000..79e0095
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsManager.java
@@ -0,0 +1,212 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.icon.AbstractIcon;
+import com.vaadin.flow.function.SerializableConsumer;
+import com.vaadin.flow.function.ValueProvider;
+import com.vaadin.flow.shared.Registration;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import lombok.NonNull;
+
+/**
+ * Manages the row actions column for an {@code EasyGrid}. Maintains the list of registered
+ * {@link EasyRowAction} instances, the render style (inline buttons, an overflow menu, or a
+ * context menu; see {@link RowActionsStyle}), and delegates all visual concerns to a
+ * {@link RowActionsRenderer}.
+ *
+ * A manager instance is created lazily, on first access through its grid wrapper. The instance
+ * itself is lightweight; the actions {@link Grid.Column} it manages is created lazily (on the first
+ * renderer update or {@link #getActionsColumn()} call) and stays hidden until the first action is
+ * added.
+ *
+ * @param the grid bean type
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+public class RowActionsManager implements Serializable {
+
+ private final Grid grid;
+ private final List> actions = new ArrayList<>();
+ private RowActionsRenderer renderer;
+ private ButtonVariant[] defaultVariants = {ButtonVariant.LUMO_TERTIARY_INLINE};
+ private boolean rendererInitialized = false;
+ private Registration rendererRegistration;
+
+ private void setRendererRegistration(Registration registration) {
+ if (rendererRegistration != null) {
+ rendererRegistration.remove();
+ }
+ rendererRegistration = registration;
+ }
+
+ /**
+ * Creates a new {@code RowActionsManager} for the given grid.
+ *
+ * @param grid the grid to manage row actions for
+ */
+ public RowActionsManager(@NonNull Grid grid) {
+ this.grid = grid;
+ this.renderer = new LitRowActionsRenderer<>(grid);
+ }
+
+ /**
+ * Creates and registers a new row action, applying the current default theme variants and
+ * scheduling a renderer rebuild. At least one of {@code labelProvider} or {@code iconProvider}
+ * must be non-{@code null}.
+ *
+ * @param the icon type
+ * @param labelProvider per-row label provider, or {@code null} for icon-only
+ * @param iconProvider per-row icon provider, or {@code null} for label-only
+ * @param handler the action to execute when the action is clicked
+ * @return the registered action
+ */
+ > EasyRowAction addRowAction(
+ ValueProvider labelProvider,
+ ValueProvider iconProvider,
+ @NonNull SerializableConsumer handler) {
+ EasyRowAction action = new EasyRowAction(this, labelProvider, iconProvider, handler);
+ if (defaultVariants != null) {
+ action.addThemeVariants(defaultVariants);
+ }
+ actions.add(action);
+ updateColumnVisibility();
+ scheduleRendererUpdate();
+ return action;
+ }
+
+ /**
+ * Removes the specified action. If the action is not currently registered, this call is a no-op.
+ * The renderer is rebuilt on the next {@code beforeClientResponse} cycle; if no actions remain
+ * and the active renderer uses a column, that column is hidden.
+ *
+ * @param action the action to remove
+ */
+ void removeRowAction(EasyRowAction action) {
+ if (!actions.remove(action)) {
+ return;
+ }
+ updateColumnVisibility();
+ scheduleRendererUpdate();
+ action.remove();
+ }
+
+ private void scheduleRendererUpdate() {
+ grid.getUI().ifPresentOrElse(
+ ui -> setRendererRegistration(ui.beforeClientResponse(grid, ctx -> updateRenderer())),
+ () -> setRendererRegistration(grid.addAttachListener(e -> setRendererRegistration(
+ e.getUI().beforeClientResponse(grid, ctx -> updateRenderer())))));
+ }
+
+ /**
+ * Sets the theme variants that are applied to every action upon creation.
+ *
+ * @param variants the variants to apply
+ */
+ void setDefaultRowActionVariants(ButtonVariant... variants) {
+ this.defaultVariants = variants != null && variants.length > 0 ? variants : null;
+ }
+
+ /**
+ * Sets the style used to present row actions, replacing the active renderer when the style
+ * changes. Has no effect when the given style is already active.
+ *
+ * @param style the row actions style
+ */
+ void setRowActionsStyle(@NonNull RowActionsStyle style) {
+ if (!style.isInstance(renderer)) {
+ setRenderer(style.createRenderer(grid));
+ }
+ }
+
+ /**
+ * Replaces the active renderer. The current renderer is cleaned up via
+ * {@link RowActionsRenderer#remove()} before the new one is installed. A renderer rebuild is
+ * scheduled for the next {@code beforeClientResponse} cycle.
+ *
+ * @param renderer the new renderer to use
+ */
+ public void setRenderer(@NonNull RowActionsRenderer renderer) {
+ this.renderer.remove();
+ rendererInitialized = false;
+ this.renderer = renderer;
+ scheduleRendererUpdate();
+ }
+
+ /**
+ * Schedules a renderer rebuild on the next {@code beforeClientResponse} cycle. The fluent
+ * configuration methods of {@link EasyRowAction} schedule this automatically; an explicit call is
+ * only needed after changing an action's styling or theme variants, which are not applied
+ * automatically. Any previously scheduled rebuild is cancelled and replaced.
+ */
+ void refresh() {
+ scheduleRendererUpdate();
+ }
+
+ /**
+ * Returns an unmodifiable view of the registered action entries.
+ *
+ * @return the list of action entries
+ */
+ List> getRowActions() {
+ return Collections.unmodifiableList(actions);
+ }
+
+ private void updateRenderer() {
+ setRendererRegistration(null);
+ renderer.update(Collections.unmodifiableList(actions));
+ rendererInitialized = true;
+ updateColumnVisibility();
+ }
+
+ /**
+ * Returns the {@code Grid.Column} that hosts the actions, or {@code null} if the active renderer
+ * does not use a column (e.g. a context-menu renderer).
+ *
+ * For column-based renderers: the column is created on demand if it does not exist yet, hidden
+ * when no actions are registered, and made visible automatically when the first action is added.
+ * If a deferred renderer update is pending it is cancelled and applied immediately so the column
+ * reflects the current action list.
+ */
+ Grid.Column getActionsColumn() {
+ if (!rendererInitialized) {
+ updateRenderer();
+ }
+ return renderer.getColumn();
+ }
+
+ /**
+ * Shows the actions column when at least one action is registered and hides it otherwise. A no-op
+ * for renderers that do not use a column.
+ */
+ private void updateColumnVisibility() {
+ var column = renderer.getColumn();
+ if (column != null) {
+ column.setVisible(!actions.isEmpty());
+ }
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsRenderer.java
new file mode 100644
index 0000000..6059171
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsRenderer.java
@@ -0,0 +1,68 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.grid.Grid;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * Strategy interface for rendering the row actions of an {@code EasyGrid}. Implementations decide
+ * how the actions are presented: as inline buttons in a dedicated column, as an overflow/context
+ * menu, or any other mechanism.
+ *
+ * An instance is held by {@link RowActionsManager} and is called whenever the action list
+ * changes and a visual refresh is needed.
+ *
+ * @param the grid bean type
+ * @see HasRowActions#setRowActionsRenderer(RowActionsRenderer)
+ * @see LitRowActionsRenderer
+ * @author Javier Godoy / Flowing Code
+ */
+public interface RowActionsRenderer extends Serializable {
+
+ /**
+ * Rebuilds the visual representation to reflect the given action list. Called on every scheduled
+ * renderer update. Implementations that use a {@link Grid.Column} should create it on the first
+ * call and update its renderer on subsequent calls. Renderers are responsible for any
+ * data-view refresh their presentation requires.
+ *
+ * @param actions the current list of registered actions (unmodifiable)
+ */
+ void update(List> actions);
+
+ /**
+ * Returns the {@code Grid.Column} used to host the actions, if this renderer uses a dedicated
+ * column. Renderers that present actions through a context menu or another mechanism not tied to a
+ * column should return {@code null}.
+ *
+ * @return the actions column, or {@code null} if not applicable
+ */
+ Grid.Column getColumn();
+
+ /**
+ * Cleans up all UI elements created by this renderer (columns, context menus, etc.). Called by
+ * {@link RowActionsManager} when the renderer is being replaced. The default implementation is a
+ * no-op.
+ */
+ default void remove() {}
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsStyle.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsStyle.java
new file mode 100644
index 0000000..2eea55e
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsStyle.java
@@ -0,0 +1,79 @@
+/*-
+ * #%L
+ * Easy Grid Add-on
+ * %%
+ * Copyright (C) 2020 - 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.easygrid.actions;
+
+import com.vaadin.flow.component.grid.Grid;
+import lombok.NonNull;
+
+/**
+ * Identifies how an {@code EasyGrid}'s row actions are presented.
+ *
+ * @see RowActionsRenderer
+ * @author Javier Godoy / Flowing Code
+ */
+public enum RowActionsStyle {
+
+ /** Actions are presented as inline buttons in a dedicated column. This is the default style. */
+ INLINE_BUTTONS,
+
+ /**
+ * Actions are presented through an overflow ("⋮") button hosted in a dedicated column; clicking
+ * the button opens the menu for that row.
+ */
+ DROPDOWN,
+
+ /**
+ * Actions are presented through the grid's right-click context menu; no dedicated column is
+ * created.
+ */
+ CONTEXT_MENU;
+
+ /**
+ * Creates a {@code RowActionsRenderer} that presents row actions in this style.
+ *
+ * @param the grid bean type
+ * @param grid the grid the renderer will decorate
+ * @return a new renderer for this style
+ */
+ RowActionsRenderer createRenderer(@NonNull Grid