Skip to content
Open
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
33 changes: 33 additions & 0 deletions docs/1-essentials/02-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,39 @@ The example above will only render the child `div` elements:
<div>Post C</div>
```

### Tag override with the `as` prop

The `as` attribute allows you to transform the rendered tag of one element into another. This takes place on an instance of `GenericElement`, so for example this code:
```html
<a as="button">My Link</a>
```
Would render
```html
<button>My Link</button>
```
The power behind this is when you use an `Expression` to determine the element.

Say for example, you wish to have a `<x-link>` component which renders as an `<a>` when the `$href` attribute is provided. In your view, use the component like so:
```html
<x-link href="https://tempestphp.com">Click to go to an awesome website</x-link>

<x-link>This is just a button</x-link>
```
In your `<x-link>` component, define:
```html
<a :as="$href ?? 'button'" :href="$href ?? ''"><x-slot /></a>
```
Your page will render two links, as follows
```html
<a href="https://tempestphp.com">Click to go to an awesome website</a>

<button>This is just a button</button>
```

#### Where this can and cannot be used

You can't use the `as` Attribute on things like `<x-template>`, `<x-slot>`, etc, as these do not themselves render any HTML. They are placeholders in the page. Nor will placing it on a view component itself inherently do anything. The `as` attribute CAN be passed to a ViewComponent as shown in the example above, but by itself it will actually do nothing, unless you specifically provide logic to place it where you want it.

## View components

Components allow for splitting the user interface into independent and reusable pieces.
Expand Down
47 changes: 47 additions & 0 deletions packages/view/src/Attributes/AsAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Tempest\View\Attributes;

use Tempest\View\Attribute;
use Tempest\View\Element;
use Tempest\View\Elements\GenericElement;
use Tempest\View\Exceptions\ExpressionAttributeWasInvalid;
use Tempest\View\Parser\TempestViewCompiler;

use function Tempest\Support\str;

final readonly class AsAttribute implements Attribute
{
public function __construct(
private string $name,
) {}

public function apply(Element $element): Element
{
$value = str($element->consumeAttribute($this->name) ?? '');

if ($value->isEmpty()) {
return $element;
}

$generic = $element->unwrap(GenericElement::class);

if ($generic === null) {
return $element;
}

// :as="expression" — follows the ExpressionAttribute convention
if (str($this->name)->startsWith(':')) {
if ($value->startsWith(['{{', '{!!', ...TempestViewCompiler::PHP_TOKENS])) {
throw new ExpressionAttributeWasInvalid($value);
}

return $generic->withTagExpression($value->toString());
}

// as="literal-tag"
return $generic->withTag($value->toString());
}
}
2 changes: 2 additions & 0 deletions packages/view/src/Attributes/AttributeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public function make(string $attributeName): Attribute
$attributeName === ':else' => new ElseAttribute(),
$attributeName === ':foreach' => new ForeachAttribute(),
$attributeName === ':forelse' => new ForelseAttribute(),
$attributeName === 'as' => new AsAttribute('as'),
$attributeName === ':as' => new AsAttribute(':as'),
str_starts_with($attributeName, '::') => new EscapedExpressionAttribute($attributeName),
str_starts_with($attributeName, ':') => new ExpressionAttribute($attributeName),
default => new DataAttribute($attributeName),
Expand Down
30 changes: 29 additions & 1 deletion packages/view/src/Elements/GenericElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ final class GenericElement implements Element, WithToken
{
use IsElement;

private ?string $tagExpression = null;

public function __construct(
public readonly Token $token,
private readonly string $tag,
private string $tag,
private readonly bool $isHtml,
array $attributes,
) {
Expand All @@ -28,6 +30,22 @@ public function getTag(): string
return $this->tag;
}

public function withTag(string $tag): self
{
$clone = clone $this;
$clone->tag = $tag;

return $clone;
}

public function withTagExpression(string $expression): self
{
$clone = clone $this;
$clone->tagExpression = $expression;

return $clone;
}

public function compile(): string
{
$content = [];
Expand All @@ -54,6 +72,16 @@ public function compile(): string
$attributes = ' ' . $attributes;
}

// When a PHP expression drives the tag, we cannot know the resolved
// name at compile time, so void-element detection is skipped and we
// always emit a full open/close pair.
if ($this->tagExpression !== null) {
$open = "<?= {$this->tagExpression} ?>";
$close = "<?= {$this->tagExpression} ?>";

return "<{$open}{$attributes}>{$content}</{$close}>";
}

// Void elements
if (is_void_tag($this->tag)) {
if ($this->isHtml) {
Expand Down
167 changes: 167 additions & 0 deletions packages/view/tests/AsAttributeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

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\view;

final class AsAttributeTest extends TestCase
{
#[Test]
public function generic_element_static_as_overrides_tag(): void
{
$html = TempestViewRenderer::make()->render(
'<a as="button"><span>Test</span></a>',
);

$this->assertSnippetsMatch('<button><span>Test</span></button>', $html);
}

#[Test]
public function generic_element_expression_as_overrides_tag(): void
{
$html = TempestViewRenderer::make()->render(
view('<a :as="$tag"><span>Test</span></a>')->data(tag: 'button'),
);

$this->assertSnippetsMatch('<button><span>Test</span></button>', $html);
}

#[Test]
public function generic_element_static_as_on_nested_element(): void
{
$html = TempestViewRenderer::make()->render(
'<div><a as="button"><span>Test</span></a></div>',
);

$this->assertSnippetsMatch('<div><button><span>Test</span></button></div>', $html);
}

#[Test]
public function generic_element_expression_as_on_nested_element(): void
{
$html = TempestViewRenderer::make()->render(
view('<div><a :as="$tag"><span>Test</span></a></div>')->data(tag: 'button'),
);

$this->assertSnippetsMatch('<div><button><span>Test</span></button></div>', $html);
}

#[Test]
public function view_component_static_as_overrides_root_tag(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(
'<x-link as="button"><span>Test</span></x-link>',
);

$this->assertSnippetsMatch('<button><span>Test</span></button>', $html);
}

#[Test]
public function view_component_expression_as_defaults_to_button_when_no_href(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(
view('<x-link :as="$href ? \'a\' : \'button\'"><span>Test</span></x-link>')->data(href: null),
);

$this->assertSnippetsMatch('<button><span>Test</span></button>', $html);
}

#[Test]
public function view_component_expression_as_resolves_to_a_when_href_is_set(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(
view('<x-link :as="$href ? \'a\' : \'button\'"><span>Test</span></x-link>')->data(href: 'https://example.com'),
);

$this->assertSnippetsMatch('<a><span>Test</span></a>', $html);
}

#[Test]
public function view_component_with_static_as_inside_generic_div(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(
'<div><x-link as="button"><span>Test</span></x-link></div>',
);

$this->assertSnippetsMatch('<div><button><span>Test</span></button></div>', $html);
}

#[Test]
public function view_component_without_as_wrapping_component_with_static_as(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(<<<'HTML'
<x-outer>
<x-link as="button"><span>Test</span></x-link>
</x-outer>
HTML);

$this->assertSnippetsMatch('<section><button><span>Test</span></button></section>', $html);
}

#[Test]
public function view_component_without_as_wrapping_component_with_expression_as(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(
view(<<<'HTML'
<x-outer>
<x-link :as="$tag ?? 'button'"><span>Test</span></x-link>
</x-outer>
HTML)->data(tag: null),
);

$this->assertSnippetsMatch('<section><button><span>Test</span></button></section>', $html);
}

#[Test]
public function view_component_without_as_wrapping_component_with_expression_as_resolved_to_a(): void
{
$renderer = $this->makeRenderer();

$html = $renderer->render(
view(<<<'HTML'
<x-outer>
<x-link :as="$tag ?? 'button'"><span>Test</span></x-link>
</x-outer>
HTML)->data(tag: 'a'),
);

$this->assertSnippetsMatch('<section><a><span>Test</span></a></section>', $html);
}

private function makeRenderer(): TempestViewRenderer
{
$viewConfig = new ViewConfig()->addViewComponents(
__DIR__ . '/Fixtures/x-link.view.php',
__DIR__ . '/Fixtures/x-outer.view.php',
);

return TempestViewRenderer::make(viewConfig: $viewConfig);
}

private function assertSnippetsMatch(string $expected, string $actual): void
{
$this->assertSame(
str_replace([PHP_EOL, ' '], '', $expected),
str_replace([PHP_EOL, ' '], '', $actual),
);
}
}
1 change: 1 addition & 0 deletions packages/view/tests/Fixtures/x-link.view.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a :as="$as ?? 'a'"><x-slot /></a>
1 change: 1 addition & 0 deletions packages/view/tests/Fixtures/x-outer.view.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<section><x-slot /></section>