Skip to content

Commit 2664e73

Browse files
committed
feat(view): support expression attribute fallthrough
1 parent 32165fb commit 2664e73

File tree

5 files changed

+84
-55
lines changed

5 files changed

+84
-55
lines changed

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) { 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) ?>',
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(', ')
@@ -165,7 +126,6 @@ public function compile(): string
165126
),
166127
);
167128

168-
// Compile slots
169129
$compiled = $compiled->replaceRegex(
170130
regex: '/<x-slot\s*(name="(?<name>[\w-]+)")?((\s*\/>)|>(?<default>(.|\n)*?)<\/x-slot>)/',
171131
replace: function ($matches) use ($slots) {
@@ -222,4 +182,77 @@ private function getSlotElement(string $name): SlotElement|CollectionElement|nul
222182

223183
return null;
224184
}
185+
186+
private function applyFallthroughAttributes(ImmutableString $compiled): ImmutableString
187+
{
188+
return $compiled->replaceRegex(
189+
regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/',
190+
replace: function (array $matches): string {
191+
/** @var Token $token */
192+
$token = TempestViewParser::ast($matches[0])[0];
193+
194+
$attributes = arr($token->htmlAttributes)
195+
->map(fn (string $value) => new MutableString($value));
196+
197+
foreach (['class', 'style', 'id'] as $name) {
198+
$attributes = $this->applyFallthroughAttribute($attributes, $name);
199+
}
200+
201+
$attributeString = $attributes
202+
->map(fn (MutableString $value, string $key) => sprintf('%s="%s"', $key, $value->trim()))
203+
->implode(' ')
204+
->when(
205+
fn (ImmutableString $s) => $s->isNotEmpty(),
206+
fn (ImmutableString $s) => $s->prepend(' '),
207+
);
208+
209+
return sprintf('<%s%s>', $matches['tag'], $attributeString);
210+
},
211+
);
212+
}
213+
214+
private function applyFallthroughAttribute(ImmutableArray $attributes, string $name): ImmutableArray
215+
{
216+
$hasDataAttribute = isset($this->dataAttributes[$name]);
217+
$hasExpressionAttribute = isset($this->expressionAttributes[$name]);
218+
219+
if (! $hasDataAttribute && ! $hasExpressionAttribute) {
220+
return $attributes;
221+
}
222+
223+
$attributes[$name] ??= new MutableString();
224+
225+
if ($name === 'id') {
226+
if ($hasDataAttribute) {
227+
$attributes[$name] = new MutableString($this->dataAttributes[$name]);
228+
} elseif ($hasExpressionAttribute) {
229+
$attributes[$name] = new MutableString(sprintf('<?= $%s ?>', $name));
230+
}
231+
} else {
232+
if ($hasDataAttribute) {
233+
$attributes[$name]->append(' ' . $this->dataAttributes[$name]);
234+
}
235+
if ($hasExpressionAttribute) {
236+
$attributes[$name]->append(sprintf(' <?= $%s ?>', $name));
237+
}
238+
}
239+
240+
return $attributes;
241+
}
242+
243+
private function exportAttributesArray(): string
244+
{
245+
$entries = [];
246+
247+
foreach ($this->viewComponentAttributes as $key => $value) {
248+
$camelKey = str($key)->camel()->toString();
249+
$isExpression = isset($this->expressionAttributes[$camelKey]);
250+
251+
$entries[] = $isExpression
252+
? sprintf("'%s' => %s", $key, $value)
253+
: sprintf("'%s' => %s", $key, ViewObjectExporter::exportValue($value));
254+
}
255+
256+
return sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries));
257+
}
225258
}

packages/view/tests/FallthroughAttributesTest.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@
33
namespace Tempest\View\Tests;
44

55
use PHPUnit\Framework\TestCase;
6-
use Tempest\View\Exceptions\ViewComponentPathWasInvalid;
7-
use Tempest\View\Exceptions\ViewComponentPathWasNotFound;
86
use Tempest\View\Renderers\TempestViewRenderer;
9-
use Tempest\View\ViewCache;
10-
use Tempest\View\ViewComponent;
117
use Tempest\View\ViewConfig;
128

139
use function Tempest\view;
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
<?php
2-
$componentClass = 'component-class';
3-
$componentStyle = 'display: block;';
1+
<?php
2+
$componentClass = 'component-class';
3+
$componentStyle = 'display: block;';
44
?><x-fallthrough-test class="component-class" />
55
<x-fallthrough-test :class="$componentClass" />
66
<x-fallthrough-dynamic-test c="component-class" s="display: block;" />
7-
<x-fallthrough-dynamic-test :c="$componentClass" :s="$componentStyle"/>
7+
<x-fallthrough-dynamic-test :c="$componentClass" :s="$componentStyle"/>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<div :class="$attributes->get('c')" :style="$attributes->get('s')"></div>
1+
<div :class="$attributes->get('c')" :style="$attributes->get('s')"></div>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<div class="in-component"></div>
1+
<div class="in-component"></div>

0 commit comments

Comments
 (0)