Skip to content

Commit 5d8bbad

Browse files
xHeavenadamkissbrendtinnocenzi
authored
feat(view): support expression attribute fallthrough (#1811)
Co-authored-by: Adam Kiss <iam@adamkiss.com> Co-authored-by: Brent Roose <brent.roose@gmail.com> Co-authored-by: Enzo Innocenzi <enzo@innocenzi.dev>
1 parent dbf25dc commit 5d8bbad

File tree

6 files changed

+126
-50
lines changed

6 files changed

+126
-50
lines changed

packages/view/src/Elements/IsElement.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,7 @@ public function getAttribute(string $name): ?string
6060
{
6161
$attributes = $this->getAttributes();
6262

63-
$originalName = $name;
64-
65-
$name = ltrim($name, ':');
66-
67-
return $attributes[$originalName] ?? $this->attributes[":{$name}"] ?? $this->attributes[$name] ?? null;
63+
return $attributes[$name] ?? null;
6864
}
6965

7066
public function setAttribute(string $name, string $value): self

packages/view/src/Elements/ViewComponentElement.php

Lines changed: 78 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ public function __construct(
4040
array $attributes,
4141
) {
4242
$this->attributes = $attributes;
43-
$this->viewComponentAttributes = arr($attributes);
43+
44+
$this->viewComponentAttributes = arr($attributes)
45+
->mapWithKeys(fn (string $value, string $key) => yield str($key)->ltrim(':')->toString() => $value);
4446

4547
$this->dataAttributes = arr($attributes)
4648
->filter(fn (string $_, string $key) => ! str_starts_with($key, ':'))
@@ -96,50 +98,10 @@ public function compile(): string
9698

9799
$compiled = str($this->viewComponent->contents);
98100

99-
// Fallthrough attributes
100-
$compiled = $compiled
101-
->replaceRegex(
102-
regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/', // Match the very first opening tag, this will never fail.
103-
replace: function ($matches) {
104-
/** @var \Tempest\View\Parser\Token $token */
105-
$token = TempestViewParser::ast($matches[0])[0];
106-
107-
$attributes = arr($token->htmlAttributes)->map(fn (string $value) => new MutableString($value));
108-
109-
foreach (['class', 'style', 'id'] as $attributeName) {
110-
if (! isset($this->dataAttributes[$attributeName])) {
111-
continue;
112-
}
113-
114-
$attributes[$attributeName] ??= new MutableString();
115-
116-
if ($attributeName === 'id') {
117-
$attributes[$attributeName] = new MutableString(' ' . $this->dataAttributes[$attributeName]);
118-
} else {
119-
$attributes[$attributeName]->append(' ' . $this->dataAttributes[$attributeName]);
120-
}
121-
}
101+
$compiled = $this->applyFallthroughAttributes($compiled);
122102

123-
return sprintf(
124-
'<%s%s>',
125-
$matches['tag'],
126-
$attributes
127-
->map(function (MutableString $value, string $key) {
128-
return sprintf('%s="%s"', $key, $value->trim());
129-
})
130-
->implode(' ')
131-
->when(
132-
fn (ImmutableString $string) => $string->isNotEmpty(),
133-
fn (ImmutableString $string) => $string->prepend(' '),
134-
),
135-
);
136-
},
137-
);
138-
139-
// Add scoped variables
140103
$compiled = $compiled
141104
->prepend(
142-
// Open the current scope
143105
sprintf(
144106
'<?php (function ($attributes, $slots, $scopedVariables %s %s %s) { extract($scopedVariables, EXTR_SKIP); ?>',
145107
$this->dataAttributes->isNotEmpty() ? ', ' . $this->dataAttributes->map(fn (string $_value, string $key) => "\${$key}")->implode(', ') : '',
@@ -148,10 +110,9 @@ public function compile(): string
148110
),
149111
)
150112
->append(
151-
// Close and call the current scope
152113
sprintf(
153114
'<?php })(attributes: %s, slots: %s, scopedVariables: [%s] + ($scopedVariables ?? $this->currentView?->data ?? []) %s %s %s) ?>',
154-
ViewObjectExporter::export($this->viewComponentAttributes),
115+
$this->exportAttributesArray(),
155116
ViewObjectExporter::export($slots),
156117
$this->scopedVariables->isNotEmpty()
157118
? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ')
@@ -168,7 +129,6 @@ public function compile(): string
168129
),
169130
);
170131

171-
// Compile slots
172132
$compiled = $compiled->replaceRegex(
173133
regex: '/<x-slot\s*(name="(?<name>[\w-]+)")?((\s*\/>)|>(?<default>(.|\n)*?)<\/x-slot>)/',
174134
replace: function ($matches) use ($slots) {
@@ -225,4 +185,77 @@ private function getSlotElement(string $name): SlotElement|CollectionElement|nul
225185

226186
return null;
227187
}
188+
189+
private function applyFallthroughAttributes(ImmutableString $compiled): ImmutableString
190+
{
191+
return $compiled->replaceRegex(
192+
regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/',
193+
replace: function (array $matches): string {
194+
/** @var Token $token */
195+
$token = TempestViewParser::ast($matches[0])[0];
196+
197+
$attributes = arr($token->htmlAttributes)
198+
->map(fn (string $value) => new MutableString($value));
199+
200+
foreach (['class', 'style', 'id'] as $name) {
201+
$attributes = $this->applyFallthroughAttribute($attributes, $name);
202+
}
203+
204+
$attributeString = $attributes
205+
->map(fn (MutableString $value, string $key) => sprintf('%s="%s"', $key, $value->trim()))
206+
->implode(' ')
207+
->when(
208+
fn (ImmutableString $s) => $s->isNotEmpty(),
209+
fn (ImmutableString $s) => $s->prepend(' '),
210+
);
211+
212+
return sprintf('<%s%s>', $matches['tag'], $attributeString);
213+
},
214+
);
215+
}
216+
217+
private function applyFallthroughAttribute(ImmutableArray $attributes, string $name): ImmutableArray
218+
{
219+
$hasDataAttribute = isset($this->dataAttributes[$name]);
220+
$hasExpressionAttribute = isset($this->expressionAttributes[$name]);
221+
222+
if (! $hasDataAttribute && ! $hasExpressionAttribute) {
223+
return $attributes;
224+
}
225+
226+
$attributes[$name] ??= new MutableString();
227+
228+
if ($name === 'id') {
229+
if ($hasDataAttribute) {
230+
$attributes[$name] = new MutableString($this->dataAttributes[$name]);
231+
} elseif ($hasExpressionAttribute) {
232+
$attributes[$name] = new MutableString(sprintf('<?= $%s ?>', $name));
233+
}
234+
} else {
235+
if ($hasDataAttribute) {
236+
$attributes[$name]->append(' ' . $this->dataAttributes[$name]);
237+
}
238+
if ($hasExpressionAttribute) {
239+
$attributes[$name]->append(sprintf(' <?= $%s ?>', $name));
240+
}
241+
}
242+
243+
return $attributes;
244+
}
245+
246+
private function exportAttributesArray(): string
247+
{
248+
$entries = [];
249+
250+
foreach ($this->viewComponentAttributes as $key => $value) {
251+
$camelKey = str($key)->camel()->toString();
252+
$isExpression = isset($this->expressionAttributes[$camelKey]);
253+
254+
$entries[] = $isExpression
255+
? sprintf("'%s' => %s", $key, $value)
256+
: sprintf("'%s' => %s", $key, ViewObjectExporter::exportValue($value));
257+
}
258+
259+
return sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries));
260+
}
228261
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Tempest\View\Tests;
4+
5+
use PHPUnit\Framework\Attributes\Test;
6+
use PHPUnit\Framework\TestCase;
7+
use Tempest\View\Renderers\TempestViewRenderer;
8+
use Tempest\View\ViewConfig;
9+
10+
use function Tempest\view;
11+
12+
final class FallthroughAttributesTest extends TestCase
13+
{
14+
#[Test]
15+
public function render(): void
16+
{
17+
$viewConfig = new ViewConfig()->addViewComponents(
18+
__DIR__ . '/Fixtures/x-fallthrough-test.view.php',
19+
__DIR__ . '/Fixtures/x-fallthrough-dynamic-test.view.php',
20+
);
21+
22+
$renderer =
23+
TempestViewRenderer::make(
24+
viewConfig: $viewConfig,
25+
);
26+
27+
$html = $renderer->render(
28+
view(__DIR__ . '/Fixtures/fallthrough.view.php'),
29+
);
30+
31+
$this->assertEquals(<<<'HTML'
32+
<div class="in-component component-class"></div>
33+
<div class="in-component component-class"></div>
34+
<div class="component-class" style="display: block;"></div>
35+
<div class="component-class" style="display: block;"></div>
36+
HTML, $html);
37+
}
38+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
$componentClass = 'component-class';
3+
$componentStyle = 'display: block;';
4+
?><x-fallthrough-test class="component-class" />
5+
<x-fallthrough-test :class="$componentClass" />
6+
<x-fallthrough-dynamic-test c="component-class" s="display: block;" />
7+
<x-fallthrough-dynamic-test :c="$componentClass" :s="$componentStyle"/>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div :class="$attributes->get('c')" :style="$attributes->get('s')"></div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div class="in-component"></div>

0 commit comments

Comments
 (0)