Skip to content

Commit ced8ace

Browse files
committed
fix(view): auto-merge class, style and id attributes on view components
1 parent accab81 commit ced8ace

File tree

2 files changed

+167
-10
lines changed

2 files changed

+167
-10
lines changed

packages/view/src/Elements/ViewComponentElement.php

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,35 @@ public function compile(): string
120120
}
121121
}
122122

123+
foreach (['class', 'style', 'id'] as $attributeName) {
124+
if (! isset($this->expressionAttributes[$attributeName])) {
125+
continue;
126+
}
127+
128+
$exprKey = ':' . $attributeName;
129+
$exprValue = $this->expressionAttributes[$attributeName];
130+
131+
if ($attributeName === 'id') {
132+
// For id, expression takes precedence over static
133+
unset($attributes['id']);
134+
$attributes[$exprKey] = new MutableString($exprValue);
135+
continue;
136+
}
137+
138+
// For class and style, merge with existing expressions if present
139+
if (isset($attributes[$exprKey])) {
140+
$existingExpr = $attributes[$exprKey]->trim()->toString();
141+
142+
// Combine expressions into an array if they're different
143+
// Combine both expressions using implode to handle arrays properly
144+
if ($existingExpr !== $exprValue) {
145+
$attributes[$exprKey] = new MutableString(sprintf("implode(' ', array_filter([%s, %s]))", $existingExpr, $exprValue));
146+
}
147+
} else {
148+
$attributes[$exprKey] = new MutableString($exprValue);
149+
}
150+
}
151+
123152
return sprintf(
124153
'<%s%s>',
125154
$matches['tag'],
@@ -137,30 +166,43 @@ public function compile(): string
137166
);
138167

139168
// Add scoped variables
169+
// Merge all attribute keys to avoid duplicate parameters
170+
$allAttributeKeys = arr()
171+
->merge($this->dataAttributes->keys())
172+
->merge($this->expressionAttributes->keys())
173+
->unique();
174+
140175
$compiled = $compiled
141176
->prepend(
142177
// Open the current scope
143178
sprintf(
144-
'<?php (function ($attributes, $slots, $scopedVariables %s %s) { extract($scopedVariables, EXTR_SKIP); ?>',
145-
$this->dataAttributes->isNotEmpty() ? (', ' . $this->dataAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ')) : '',
146-
$this->expressionAttributes->isNotEmpty() ? (', ' . $this->expressionAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ')) : '',
147-
$this->scopedVariables->isNotEmpty() ? (', ' . $this->scopedVariables->map(fn (string $name) => "\${$name}")->implode(', ')) : '',
179+
'<?php (function ($attributes, $slots, $scopedVariables %s) { extract($scopedVariables, EXTR_SKIP); ?>',
180+
$allAttributeKeys->isNotEmpty() ? (', ' . $allAttributeKeys->map(fn (string $key) => "\${$key}")->implode(', ')) : '',
148181
),
149182
)
150183
->append(
151184
// Close and call the current scope
152185
sprintf(
153-
'<?php })(attributes: %s, slots: %s, scopedVariables: [%s] + ($scopedVariables ?? $this->currentView?->data ?? []) %s %s) ?>',
186+
'<?php })(attributes: %s, slots: %s, scopedVariables: [%s] + ($scopedVariables ?? $this->currentView?->data ?? []) %s) ?>',
154187
ViewObjectExporter::export($this->viewComponentAttributes),
155188
ViewObjectExporter::export($slots),
156189
$this->scopedVariables->isNotEmpty()
157190
? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ')
158191
: '',
159-
$this->dataAttributes->isNotEmpty()
160-
? (', ' . $this->dataAttributes->map(fn (mixed $value, string $key) => "{$key}: " . ViewObjectExporter::exportValue($value))->implode(', '))
161-
: '',
162-
$this->expressionAttributes->isNotEmpty()
163-
? (', ' . $this->expressionAttributes->map(fn (mixed $value, string $key) => "{$key}: " . $value)->implode(', '))
192+
// Merge data and expression attributes, with expression taking precedence to avoid duplicates
193+
$this->dataAttributes
194+
->merge($this->expressionAttributes->mapWithKeys(fn ($value, $key) => yield $key => $value))
195+
->isNotEmpty()
196+
? (
197+
', ' .
198+
$this->dataAttributes
199+
->filter(fn ($_value, $key) => ! isset($this->expressionAttributes[$key]))
200+
->merge($this->expressionAttributes)
201+
->map(fn (mixed $value, string $key) => isset($this->expressionAttributes[$key])
202+
? "{$key}: {$value}"
203+
: ("{$key}: " . ViewObjectExporter::exportValue($value)))
204+
->implode(', ')
205+
)
164206
: '',
165207
),
166208
);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\View;
6+
7+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
8+
9+
final class ViewComponentAttributeMergeTest extends FrameworkIntegrationTestCase
10+
{
11+
public function test_merges_plain_class_attribute(): void
12+
{
13+
$this->registerViewComponent('x-test-btn', '<button class="base-class">
14+
<x-slot />
15+
</button>');
16+
17+
$html = $this->render('<x-test-btn class="custom-class">Click</x-test-btn>');
18+
19+
$this->assertStringContainsString('class="base-class custom-class"', $html);
20+
}
21+
22+
public function test_merges_expression_class_attribute(): void
23+
{
24+
$this->registerViewComponent('x-test-btn2', '<button class="base-class">
25+
<x-slot />
26+
</button>');
27+
28+
$html = $this->render(
29+
'<x-test-btn2 :class="$customClass">Click</x-test-btn2>',
30+
customClass: 'dynamic-class',
31+
);
32+
33+
$this->assertStringContainsString('class="base-class dynamic-class"', $html);
34+
}
35+
36+
public function test_merges_both_plain_and_expression_class(): void
37+
{
38+
$this->registerViewComponent('x-test-btn3', '<button class="base-class">
39+
<x-slot />
40+
</button>');
41+
42+
$html = $this->render(
43+
'<x-test-btn3 class="plain-class" :class="$dynamicClass">Click</x-test-btn3>',
44+
dynamicClass: 'dynamic-class',
45+
);
46+
47+
$this->assertStringContainsString('class="base-class plain-class dynamic-class"', $html);
48+
}
49+
50+
public function test_merges_plain_style_attribute(): void
51+
{
52+
$this->registerViewComponent('x-test-btn4', '<button style="color: blue;">
53+
<x-slot />
54+
</button>');
55+
56+
$html = $this->render('<x-test-btn4 style="font-weight: bold;">Click</x-test-btn4>');
57+
58+
$this->assertStringContainsString('style="color: blue; font-weight: bold;"', $html);
59+
}
60+
61+
public function test_merges_expression_style_attribute(): void
62+
{
63+
$this->registerViewComponent('x-test-btn5', '<button style="color: blue;">
64+
<x-slot />
65+
</button>');
66+
67+
$html = $this->render(
68+
'<x-test-btn5 :style="$customStyle">Click</x-test-btn5>',
69+
customStyle: 'font-weight: bold;',
70+
);
71+
72+
$this->assertStringContainsString('style="color: blue; font-weight: bold;"', $html);
73+
}
74+
75+
public function test_replaces_id_with_expression(): void
76+
{
77+
$this->registerViewComponent('x-test-btn6', '<button id="default-id">
78+
<x-slot />
79+
</button>');
80+
81+
$html = $this->render(
82+
'<x-test-btn6 :id="$customId">Click</x-test-btn6>',
83+
customId: 'dynamic-id',
84+
);
85+
86+
$this->assertStringContainsString('id="dynamic-id"', $html);
87+
$this->assertStringNotContainsString('id="default-id"', $html);
88+
}
89+
90+
public function test_combines_component_and_usage_expression_classes(): void
91+
{
92+
$this->registerViewComponent('x-test-btn7', '<button class="base" :class="$isActive ? \'active\' : \'\'">
93+
<x-slot />
94+
</button>');
95+
96+
$html = $this->render(
97+
'<x-test-btn7 :class="$isDanger ? \'danger\' : \'\'">Click</x-test-btn7>',
98+
isActive: true,
99+
isDanger: true,
100+
);
101+
102+
$this->assertStringContainsString('class="base active danger"', $html);
103+
}
104+
105+
public function test_complex_usage_scenario(): void
106+
{
107+
$this->registerViewComponent('x-act-btn', '<button class="bg-gray-100 px-2 py-1">
108+
<x-slot />
109+
</button>');
110+
111+
$html = $this->render('<x-act-btn class="text-red-200">Click Me</x-act-btn>');
112+
113+
$this->assertStringContainsString('class="bg-gray-100 px-2 py-1 text-red-200"', $html);
114+
}
115+
}

0 commit comments

Comments
 (0)