diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php
index bd9dd0db3..6e4daec03 100644
--- a/packages/view/src/Elements/ViewComponentElement.php
+++ b/packages/view/src/Elements/ViewComponentElement.php
@@ -120,6 +120,35 @@ public function compile(): string
}
}
+ foreach (['class', 'style', 'id'] as $attributeName) {
+ if (! isset($this->expressionAttributes[$attributeName])) {
+ continue;
+ }
+
+ $exprKey = ':' . $attributeName;
+ $exprValue = $this->expressionAttributes[$attributeName];
+
+ if ($attributeName === 'id') {
+ // For id, expression takes precedence over static
+ unset($attributes['id']);
+ $attributes[$exprKey] = new MutableString($exprValue);
+ continue;
+ }
+
+ // For class and style, merge with existing expressions if present
+ if (isset($attributes[$exprKey])) {
+ $existingExpr = $attributes[$exprKey]->trim()->toString();
+
+ // Combine expressions into an array if they're different
+ // Combine both expressions using implode to handle arrays properly
+ if ($existingExpr !== $exprValue) {
+ $attributes[$exprKey] = new MutableString(sprintf("implode(' ', array_filter([%s, %s]))", $existingExpr, $exprValue));
+ }
+ } else {
+ $attributes[$exprKey] = new MutableString($exprValue);
+ }
+ }
+
return sprintf(
'<%s%s>',
$matches['tag'],
@@ -137,30 +166,43 @@ public function compile(): string
);
// Add scoped variables
+ // Merge all attribute keys to avoid duplicate parameters
+ $allAttributeKeys = arr()
+ ->merge($this->dataAttributes->keys())
+ ->merge($this->expressionAttributes->keys())
+ ->unique();
+
$compiled = $compiled
->prepend(
// Open the current scope
sprintf(
- '',
- $this->dataAttributes->isNotEmpty() ? (', ' . $this->dataAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ')) : '',
- $this->expressionAttributes->isNotEmpty() ? (', ' . $this->expressionAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ')) : '',
- $this->scopedVariables->isNotEmpty() ? (', ' . $this->scopedVariables->map(fn (string $name) => "\${$name}")->implode(', ')) : '',
+ '',
+ $allAttributeKeys->isNotEmpty() ? (', ' . $allAttributeKeys->map(fn (string $key) => "\${$key}")->implode(', ')) : '',
),
)
->append(
// Close and call the current scope
sprintf(
- 'currentView?->data ?? []) %s %s) ?>',
+ 'currentView?->data ?? []) %s) ?>',
ViewObjectExporter::export($this->viewComponentAttributes),
ViewObjectExporter::export($slots),
$this->scopedVariables->isNotEmpty()
? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ')
: '',
- $this->dataAttributes->isNotEmpty()
- ? (', ' . $this->dataAttributes->map(fn (mixed $value, string $key) => "{$key}: " . ViewObjectExporter::exportValue($value))->implode(', '))
- : '',
- $this->expressionAttributes->isNotEmpty()
- ? (', ' . $this->expressionAttributes->map(fn (mixed $value, string $key) => "{$key}: " . $value)->implode(', '))
+ // Merge data and expression attributes, with expression taking precedence to avoid duplicates
+ $this->dataAttributes
+ ->merge($this->expressionAttributes->mapWithKeys(fn ($value, $key) => yield $key => $value))
+ ->isNotEmpty()
+ ? (
+ ', ' .
+ $this->dataAttributes
+ ->filter(fn ($_value, $key) => ! isset($this->expressionAttributes[$key]))
+ ->merge($this->expressionAttributes)
+ ->map(fn (mixed $value, string $key) => isset($this->expressionAttributes[$key])
+ ? "{$key}: {$value}"
+ : ("{$key}: " . ViewObjectExporter::exportValue($value)))
+ ->implode(', ')
+ )
: '',
),
);
diff --git a/tests/Integration/View/ViewComponentAttributeMergeTest.php b/tests/Integration/View/ViewComponentAttributeMergeTest.php
new file mode 100644
index 000000000..e2631184f
--- /dev/null
+++ b/tests/Integration/View/ViewComponentAttributeMergeTest.php
@@ -0,0 +1,115 @@
+registerViewComponent('x-test-btn', '');
+
+ $html = $this->render('Click');
+
+ $this->assertStringContainsString('class="base-class custom-class"', $html);
+ }
+
+ public function test_merges_expression_class_attribute(): void
+ {
+ $this->registerViewComponent('x-test-btn2', '');
+
+ $html = $this->render(
+ 'Click',
+ customClass: 'dynamic-class',
+ );
+
+ $this->assertStringContainsString('class="base-class dynamic-class"', $html);
+ }
+
+ public function test_merges_both_plain_and_expression_class(): void
+ {
+ $this->registerViewComponent('x-test-btn3', '');
+
+ $html = $this->render(
+ 'Click',
+ dynamicClass: 'dynamic-class',
+ );
+
+ $this->assertStringContainsString('class="base-class plain-class dynamic-class"', $html);
+ }
+
+ public function test_merges_plain_style_attribute(): void
+ {
+ $this->registerViewComponent('x-test-btn4', '');
+
+ $html = $this->render('Click');
+
+ $this->assertStringContainsString('style="color: blue; font-weight: bold;"', $html);
+ }
+
+ public function test_merges_expression_style_attribute(): void
+ {
+ $this->registerViewComponent('x-test-btn5', '');
+
+ $html = $this->render(
+ 'Click',
+ customStyle: 'font-weight: bold;',
+ );
+
+ $this->assertStringContainsString('style="color: blue; font-weight: bold;"', $html);
+ }
+
+ public function test_replaces_id_with_expression(): void
+ {
+ $this->registerViewComponent('x-test-btn6', '');
+
+ $html = $this->render(
+ 'Click',
+ customId: 'dynamic-id',
+ );
+
+ $this->assertStringContainsString('id="dynamic-id"', $html);
+ $this->assertStringNotContainsString('id="default-id"', $html);
+ }
+
+ public function test_combines_component_and_usage_expression_classes(): void
+ {
+ $this->registerViewComponent('x-test-btn7', '');
+
+ $html = $this->render(
+ 'Click',
+ isActive: true,
+ isDanger: true,
+ );
+
+ $this->assertStringContainsString('class="base active danger"', $html);
+ }
+
+ public function test_complex_usage_scenario(): void
+ {
+ $this->registerViewComponent('x-act-btn', '');
+
+ $html = $this->render('Click Me');
+
+ $this->assertStringContainsString('class="bg-gray-100 px-2 py-1 text-red-200"', $html);
+ }
+}