Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 52 additions & 10 deletions packages/view/src/Elements/ViewComponentElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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(
'<?php (function ($attributes, $slots, $scopedVariables %s %s) { extract($scopedVariables, EXTR_SKIP); ?>',
$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(', ')) : '',
'<?php (function ($attributes, $slots, $scopedVariables %s) { extract($scopedVariables, EXTR_SKIP); ?>',
$allAttributeKeys->isNotEmpty() ? (', ' . $allAttributeKeys->map(fn (string $key) => "\${$key}")->implode(', ')) : '',
),
)
->append(
// Close and call the current scope
sprintf(
'<?php })(attributes: %s, slots: %s, scopedVariables: [%s] + ($scopedVariables ?? $this->currentView?->data ?? []) %s %s) ?>',
'<?php })(attributes: %s, slots: %s, scopedVariables: [%s] + ($scopedVariables ?? $this->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(', ')
)
: '',
),
);
Expand Down
115 changes: 115 additions & 0 deletions tests/Integration/View/ViewComponentAttributeMergeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\View;

use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

final class ViewComponentAttributeMergeTest extends FrameworkIntegrationTestCase
{
public function test_merges_plain_class_attribute(): void
{
$this->registerViewComponent('x-test-btn', '<button class="base-class">
<x-slot />
</button>');

$html = $this->render('<x-test-btn class="custom-class">Click</x-test-btn>');

$this->assertStringContainsString('class="base-class custom-class"', $html);
}

public function test_merges_expression_class_attribute(): void
{
$this->registerViewComponent('x-test-btn2', '<button class="base-class">
<x-slot />
</button>');

$html = $this->render(
'<x-test-btn2 :class="$customClass">Click</x-test-btn2>',
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', '<button class="base-class">
<x-slot />
</button>');

$html = $this->render(
'<x-test-btn3 class="plain-class" :class="$dynamicClass">Click</x-test-btn3>',
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', '<button style="color: blue;">
<x-slot />
</button>');

$html = $this->render('<x-test-btn4 style="font-weight: bold;">Click</x-test-btn4>');

$this->assertStringContainsString('style="color: blue; font-weight: bold;"', $html);
}

public function test_merges_expression_style_attribute(): void
{
$this->registerViewComponent('x-test-btn5', '<button style="color: blue;">
<x-slot />
</button>');

$html = $this->render(
'<x-test-btn5 :style="$customStyle">Click</x-test-btn5>',
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', '<button id="default-id">
<x-slot />
</button>');

$html = $this->render(
'<x-test-btn6 :id="$customId">Click</x-test-btn6>',
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', '<button class="base" :class="$isActive ? \'active\' : \'\'">
<x-slot />
</button>');

$html = $this->render(
'<x-test-btn7 :class="$isDanger ? \'danger\' : \'\'">Click</x-test-btn7>',
isActive: true,
isDanger: true,
);

$this->assertStringContainsString('class="base active danger"', $html);
}

public function test_complex_usage_scenario(): void
{
$this->registerViewComponent('x-act-btn', '<button class="bg-gray-100 px-2 py-1">
<x-slot />
</button>');

$html = $this->render('<x-act-btn class="text-red-200">Click Me</x-act-btn>');

$this->assertStringContainsString('class="bg-gray-100 px-2 py-1 text-red-200"', $html);
}
}
Loading