diff --git a/.idea/phpunit.xml b/.idea/phpunit.xml
new file mode 100644
index 0000000..4f8104c
--- /dev/null
+++ b/.idea/phpunit.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/symfony2.xml b/.idea/symfony2.xml
new file mode 100644
index 0000000..bd98e40
--- /dev/null
+++ b/.idea/symfony2.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/actions-and-cells.md b/docs/actions-and-cells.md
index 054aafa..8efe3b0 100644
--- a/docs/actions-and-cells.md
+++ b/docs/actions-and-cells.md
@@ -223,6 +223,46 @@ The host application must load the icon CSS if it wants icons to appear.
More details are available in [`icons.md`](icons.md).
+## Action icon rendering
+
+Actions can define an optional icon CSS class:
+
+```php
+$definition->addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ label: 'View',
+ icon: 'bi bi-eye',
+ routeParameters: [
+ 'id' => 'e.id',
+ ],
+);
+```
+
+Icons render before the label by default.
+
+To render the icon after the label:
+
+```php
+use Zhortein\DatatableBundle\Enum\ActionIconPosition;
+
+$definition->addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ label: 'View',
+ icon: 'bi bi-arrow-right',
+ iconPosition: ActionIconPosition::After,
+ routeParameters: [
+ 'id' => 'e.id',
+ ],
+);
+```
+
+The bundle does not require a specific icon library.
+
+The host application must load the relevant icon CSS.
+
+
## Typed cell templates
The renderer supports type-specific cell templates.
diff --git a/docs/architecture.md b/docs/architecture.md
index 09c495c..2e8268b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1111,7 +1111,26 @@ The bundle does not require Bootstrap Icons, FontAwesome, Symfony UX Icons or an
Action icons render as decorative spans with `aria-hidden="true"`, while the action label remains visible and accessible.
-The strategy is documented in `docs/icons.md`.
+The strategy is documented in [`icons.md`](icons.md).
+
+### Action icon rendering options
+
+Actions can render optional CSS-class based icons.
+
+The current strategy remains dependency-free:
+
+- no mandatory Bootstrap Icons dependency;
+- no mandatory FontAwesome dependency;
+- no SVG icon provider yet.
+
+Action icons render as decorative spans with `aria-hidden="true"`.
+
+Supported icon positions:
+
+- before label;
+- after label.
+
+Labels remain visible to preserve accessibility.
### Cell template reference
diff --git a/src/Definition/ActionDefinition.php b/src/Definition/ActionDefinition.php
index edc80a5..cafb446 100644
--- a/src/Definition/ActionDefinition.php
+++ b/src/Definition/ActionDefinition.php
@@ -4,6 +4,8 @@
namespace Zhortein\DatatableBundle\Definition;
+use Zhortein\DatatableBundle\Enum\ActionIconPosition;
+
final readonly class ActionDefinition
{
/**
@@ -15,6 +17,7 @@ public function __construct(
private string $route,
private ?string $label = null,
private ?string $icon = null,
+ private ActionIconPosition $iconPosition = ActionIconPosition::Before,
private string $httpMethod = 'GET',
private ?string $confirmationMessage = null,
private ?string $className = null,
@@ -43,6 +46,11 @@ public function getIcon(): ?string
return $this->icon;
}
+ public function getIconPosition(): ActionIconPosition
+ {
+ return $this->iconPosition;
+ }
+
public function getHttpMethod(): string
{
return $this->httpMethod;
diff --git a/src/Definition/DatatableDefinition.php b/src/Definition/DatatableDefinition.php
index 090de90..5e734f6 100644
--- a/src/Definition/DatatableDefinition.php
+++ b/src/Definition/DatatableDefinition.php
@@ -4,6 +4,7 @@
namespace Zhortein\DatatableBundle\Definition;
+use Zhortein\DatatableBundle\Enum\ActionIconPosition;
use Zhortein\DatatableBundle\Enum\FilterOperator;
use Zhortein\DatatableBundle\Enum\FilterType;
use Zhortein\DatatableBundle\Enum\JoinType;
@@ -198,6 +199,7 @@ public function addRowAction(
string $route,
?string $label = null,
?string $icon = null,
+ ActionIconPosition $iconPosition = ActionIconPosition::Before,
string $httpMethod = 'GET',
?string $confirmationMessage = null,
?string $className = null,
@@ -209,6 +211,7 @@ public function addRowAction(
route: $route,
label: $label,
icon: $icon,
+ iconPosition: $iconPosition,
httpMethod: $httpMethod,
confirmationMessage: $confirmationMessage,
className: $className,
@@ -236,6 +239,7 @@ public function addGlobalAction(
string $route,
?string $label = null,
?string $icon = null,
+ ActionIconPosition $iconPosition = ActionIconPosition::Before,
string $httpMethod = 'GET',
?string $confirmationMessage = null,
?string $className = null,
@@ -247,6 +251,7 @@ public function addGlobalAction(
route: $route,
label: $label,
icon: $icon,
+ iconPosition: $iconPosition,
httpMethod: $httpMethod,
confirmationMessage: $confirmationMessage,
className: $className,
diff --git a/src/Enum/ActionIconPosition.php b/src/Enum/ActionIconPosition.php
new file mode 100644
index 0000000..fa2db8d
--- /dev/null
+++ b/src/Enum/ActionIconPosition.php
@@ -0,0 +1,20 @@
+}
+ * @return array{name: string, label: string|null, icon: string|null, iconPosition: string, url: string, httpMethod: string, confirmationMessage: string|null, csrfToken: string|null, className: string|null, attributes: array}
*/
private function normalizeAction(ActionDefinition $action, string $url): array
{
@@ -288,6 +288,7 @@ private function normalizeAction(ActionDefinition $action, string $url): array
'name' => $action->getName(),
'label' => $action->getLabel(),
'icon' => $action->getIcon(),
+ 'iconPosition' => $action->getIconPosition()->value,
'url' => $url,
'httpMethod' => $httpMethod,
'confirmationMessage' => $action->getConfirmationMessage(),
diff --git a/templates/bootstrap/_action.html.twig b/templates/bootstrap/_action.html.twig
index 764c4ef..c651d8d 100644
--- a/templates/bootstrap/_action.html.twig
+++ b/templates/bootstrap/_action.html.twig
@@ -1,3 +1,7 @@
+{% set has_icon = action.icon is not null and action.icon is not empty %}
+{% set icon_position = action.iconPosition ?? 'before' %}
+{% set label = action.label ?? action.name %}
+
{% if action.httpMethod == 'GET' %}
- {% if action.icon is not null %}
-
+ {% if has_icon and icon_position == 'before' %}
+
{% endif %}
- {{ action.label ?? action.name }}
+ {{ label }}
+
+ {% if has_icon and icon_position == 'after' %}
+
+ {% endif %}
{% else %}
{% endif %}
diff --git a/tests/Unit/Enum/ActionIconPositionTest.php b/tests/Unit/Enum/ActionIconPositionTest.php
new file mode 100644
index 0000000..36c3cad
--- /dev/null
+++ b/tests/Unit/Enum/ActionIconPositionTest.php
@@ -0,0 +1,25 @@
+addColumn('e.email', label: 'Email')
+ ->addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ label: 'View',
+ icon: 'bi bi-eye',
+ routeParameters: ['id' => 'e.id'],
+ )
+ ;
+
+ $html = $this->createRenderer()->renderBody($definition, $this->createResult());
+
+ self::assertStringContainsString('', $html);
+ self::assertMatchesRegularExpression('/bi bi-eye me-1.*View<\/span>/s', $html);
+ }
+
+ public function test_it_renders_icon_after_action_label_when_configured(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition
+ ->addColumn('e.email', label: 'Email')
+ ->addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ label: 'View',
+ icon: 'bi bi-arrow-right',
+ iconPosition: ActionIconPosition::After,
+ routeParameters: ['id' => 'e.id'],
+ )
+ ;
+
+ $html = $this->createRenderer()->renderBody($definition, $this->createResult());
+
+ self::assertStringContainsString('', $html);
+ self::assertMatchesRegularExpression('/View<\/span>.*bi bi-arrow-right ms-1/s', $html);
+ }
+
+ public function test_it_renders_action_without_icon(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition
+ ->addColumn('e.email', label: 'Email')
+ ->addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ label: 'View',
+ routeParameters: ['id' => 'e.id'],
+ )
+ ;
+
+ $html = $this->createRenderer()->renderBody($definition, $this->createResult());
+
+ self::assertStringContainsString('View', $html);
+ self::assertStringNotContainsString('aria-hidden="true"', $html);
+ }
+
+ public function test_it_renders_global_action_icon(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition
+ ->addColumn('e.email', label: 'Email')
+ ->addGlobalAction(
+ name: 'create',
+ route: 'app_user_create',
+ label: 'Create user',
+ icon: 'bi bi-plus-lg',
+ )
+ ;
+
+ $html = $this->createRenderer()->render($definition, [
+ 'columnVisibility' => false,
+ 'export' => false,
+ ]);
+
+ self::assertStringContainsString('bi bi-plus-lg me-1', $html);
+ self::assertStringContainsString('Create user', $html);
+ }
+
+ private function createRenderer(): DatatableRenderer
+ {
+ return new DatatableRenderer(
+ twig: $this->createTwigEnvironment(),
+ urlGenerator: new IconTestUrlGenerator(),
+ routeParameterResolver: new RowActionRouteParameterResolver(),
+ actionVisibilityChecker: new AllowAllActionVisibilityChecker(),
+ );
+ }
+
+ private function createResult(): DatatableResult
+ {
+ return new DatatableResult(
+ rows: [
+ [
+ 'e_id' => 42,
+ 'e_email' => 'alice@example.test',
+ ],
+ ],
+ totalItems: 1,
+ );
+ }
+
+ private function createTwigEnvironment(): Environment
+ {
+ $loader = new FilesystemLoader();
+ $loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');
+
+ $twig = new Environment($loader, [
+ 'strict_variables' => true,
+ 'autoescape' => 'html',
+ ]);
+
+ $this->addTranslationExtension($twig);
+
+ return $twig;
+ }
+}
+
+final class IconTestUrlGenerator implements UrlGeneratorInterface
+{
+ /**
+ * @param array $parameters
+ */
+ public function generate(
+ string $name,
+ array $parameters = [],
+ int $referenceType = self::ABSOLUTE_PATH,
+ ): string {
+ $id = $parameters['id'] ?? null;
+
+ if ('app_user_show' === $name && (is_string($id) || is_int($id))) {
+ return '/users/'.$id;
+ }
+
+ if ('app_user_create' === $name) {
+ return '/users/create';
+ }
+
+ return '/'.$name;
+ }
+
+ public function setContext(RequestContext $context): void
+ {
+ }
+
+ public function getContext(): RequestContext
+ {
+ return new RequestContext();
+ }
+}