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); + } +}