Skip to content
Open
6 changes: 1 addition & 5 deletions packages/view/src/Elements/IsElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,7 @@ public function getAttribute(string $name): ?string
{
$attributes = $this->getAttributes();

$originalName = $name;

$name = ltrim($name, ':');

return $attributes[$originalName] ?? $this->attributes[":{$name}"] ?? $this->attributes[$name] ?? null;
return $attributes[$name] ?? null;
}

public function setAttribute(string $name, string $value): self
Expand Down
123 changes: 78 additions & 45 deletions packages/view/src/Elements/ViewComponentElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public function __construct(
array $attributes,
) {
$this->attributes = $attributes;
$this->viewComponentAttributes = arr($attributes);

$this->viewComponentAttributes = arr($attributes)
->mapWithKeys(fn (string $value, string $key) => yield str($key)->ltrim(':')->toString() => $value);

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

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

// Fallthrough attributes
$compiled = $compiled
->replaceRegex(
regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/', // Match the very first opening tag, this will never fail.
replace: function ($matches) {
/** @var \Tempest\View\Parser\Token $token */
$token = TempestViewParser::ast($matches[0])[0];

$attributes = arr($token->htmlAttributes)->map(fn (string $value) => new MutableString($value));

foreach (['class', 'style', 'id'] as $attributeName) {
if (! isset($this->dataAttributes[$attributeName])) {
continue;
}

$attributes[$attributeName] ??= new MutableString();

if ($attributeName === 'id') {
$attributes[$attributeName] = new MutableString(' ' . $this->dataAttributes[$attributeName]);
} else {
$attributes[$attributeName]->append(' ' . $this->dataAttributes[$attributeName]);
}
}
$compiled = $this->applyFallthroughAttributes($compiled);

return sprintf(
'<%s%s>',
$matches['tag'],
$attributes
->map(function (MutableString $value, string $key) {
return sprintf('%s="%s"', $key, $value->trim());
})
->implode(' ')
->when(
fn (ImmutableString $string) => $string->isNotEmpty(),
fn (ImmutableString $string) => $string->prepend(' '),
),
);
},
);

// Add scoped variables
$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(', ') : '',
Expand All @@ -148,10 +110,9 @@ public function compile(): string
),
)
->append(
// Close and call the current scope
sprintf(
'<?php })(attributes: %s, slots: %s, scopedVariables: [%s] + ($scopedVariables ?? $this->currentView?->data ?? []) %s %s) ?>',
ViewObjectExporter::export($this->viewComponentAttributes),
$this->exportAttributesArray(),
ViewObjectExporter::export($slots),
$this->scopedVariables->isNotEmpty()
? $this->scopedVariables->map(fn (string $name) => "'{$name}' => \${$name}")->implode(', ')
Expand All @@ -165,7 +126,6 @@ public function compile(): string
),
);

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

return null;
}

private function applyFallthroughAttributes(ImmutableString $compiled): ImmutableString
{
return $compiled->replaceRegex(
regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/',
replace: function (array $matches): string {
/** @var Token $token */
$token = TempestViewParser::ast($matches[0])[0];

$attributes = arr($token->htmlAttributes)
->map(fn (string $value) => new MutableString($value));

foreach (['class', 'style', 'id'] as $name) {
$attributes = $this->applyFallthroughAttribute($attributes, $name);
}

$attributeString = $attributes
->map(fn (MutableString $value, string $key) => sprintf('%s="%s"', $key, $value->trim()))
->implode(' ')
->when(
fn (ImmutableString $s) => $s->isNotEmpty(),
fn (ImmutableString $s) => $s->prepend(' '),
);

return sprintf('<%s%s>', $matches['tag'], $attributeString);
},
);
}

private function applyFallthroughAttribute(ImmutableArray $attributes, string $name): ImmutableArray
{
$hasDataAttribute = isset($this->dataAttributes[$name]);
$hasExpressionAttribute = isset($this->expressionAttributes[$name]);

if (! $hasDataAttribute && ! $hasExpressionAttribute) {
return $attributes;
}

$attributes[$name] ??= new MutableString();

if ($name === 'id') {
if ($hasDataAttribute) {
$attributes[$name] = new MutableString($this->dataAttributes[$name]);
} elseif ($hasExpressionAttribute) {
$attributes[$name] = new MutableString(sprintf('<?= $%s ?>', $name));
}
} else {
if ($hasDataAttribute) {
$attributes[$name]->append(' ' . $this->dataAttributes[$name]);
}
if ($hasExpressionAttribute) {
$attributes[$name]->append(sprintf(' <?= $%s ?>', $name));
}
}

return $attributes;
}

private function exportAttributesArray(): string
{
$entries = [];

foreach ($this->viewComponentAttributes as $key => $value) {
$camelKey = str($key)->camel()->toString();
$isExpression = isset($this->expressionAttributes[$camelKey]);

$entries[] = $isExpression
? sprintf("'%s' => %s", $key, $value)
: sprintf("'%s' => %s", $key, ViewObjectExporter::exportValue($value));
}

return sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries));
}
}
38 changes: 38 additions & 0 deletions packages/view/tests/FallthroughAttributesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Tempest\View\Tests;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tempest\View\Renderers\TempestViewRenderer;
use Tempest\View\ViewConfig;

use function Tempest\view;

final class FallthroughAttributesTest extends TestCase
{
#[Test]
public function render(): void
{
$viewConfig = new ViewConfig()->addViewComponents(
__DIR__ . '/Fixtures/x-fallthrough-test.view.php',
__DIR__ . '/Fixtures/x-fallthrough-dynamic-test.view.php',
);

$renderer =
TempestViewRenderer::make(
viewConfig: $viewConfig,
);

$html = $renderer->render(
view(__DIR__ . '/Fixtures/fallthrough.view.php'),
);

$this->assertEquals(<<<'HTML'
<div class="in-component component-class"></div>
<div class="in-component component-class"></div>
<div class="component-class" style="display: block;"></div>
<div class="component-class" style="display: block;"></div>
HTML, $html);
}
}
7 changes: 7 additions & 0 deletions packages/view/tests/Fixtures/fallthrough.view.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php
$componentClass = 'component-class';
$componentStyle = 'display: block;';
?><x-fallthrough-test class="component-class" />
<x-fallthrough-test :class="$componentClass" />
<x-fallthrough-dynamic-test c="component-class" s="display: block;" />
<x-fallthrough-dynamic-test :c="$componentClass" :s="$componentStyle"/>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div :class="$attributes->get('c')" :style="$attributes->get('s')"></div>
1 change: 1 addition & 0 deletions packages/view/tests/Fixtures/x-fallthrough-test.view.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="in-component"></div>