From fe88be73f91c67f7e3f0a3fde255c76348ef1340 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Wed, 11 Mar 2026 12:42:14 +0000 Subject: [PATCH 1/4] feat(view): add AsAttribute to allow dynamic HTML tag override --- packages/view/src/Attributes/AsAttribute.php | 47 +++++++++++++++++++ .../view/src/Attributes/AttributeFactory.php | 2 + packages/view/src/Elements/GenericElement.php | 30 +++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 packages/view/src/Attributes/AsAttribute.php diff --git a/packages/view/src/Attributes/AsAttribute.php b/packages/view/src/Attributes/AsAttribute.php new file mode 100644 index 0000000000..68a62f0212 --- /dev/null +++ b/packages/view/src/Attributes/AsAttribute.php @@ -0,0 +1,47 @@ +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()); + } +} diff --git a/packages/view/src/Attributes/AttributeFactory.php b/packages/view/src/Attributes/AttributeFactory.php index a62a2cbb14..ce1f9eced5 100644 --- a/packages/view/src/Attributes/AttributeFactory.php +++ b/packages/view/src/Attributes/AttributeFactory.php @@ -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), diff --git a/packages/view/src/Elements/GenericElement.php b/packages/view/src/Elements/GenericElement.php index b0266a9b70..fdb9849242 100644 --- a/packages/view/src/Elements/GenericElement.php +++ b/packages/view/src/Elements/GenericElement.php @@ -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, ) { @@ -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 = []; @@ -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 = "tagExpression} ?>"; + $close = "tagExpression} ?>"; + + return "<{$open}{$attributes}>{$content}"; + } + // Void elements if (is_void_tag($this->tag)) { if ($this->isHtml) { From 2af75a676f1c36dd35b0879458c3eb3cb6d4f9b3 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Wed, 11 Mar 2026 12:42:30 +0000 Subject: [PATCH 2/4] tests(view): tests for AsAttribute --- packages/view/tests/AsAttributeTest.php | 206 ++++++++++++++++++ packages/view/tests/Fixtures/x-link.view.php | 1 + packages/view/tests/Fixtures/x-outer.view.php | 1 + 3 files changed, 208 insertions(+) create mode 100644 packages/view/tests/AsAttributeTest.php create mode 100644 packages/view/tests/Fixtures/x-link.view.php create mode 100644 packages/view/tests/Fixtures/x-outer.view.php diff --git a/packages/view/tests/AsAttributeTest.php b/packages/view/tests/AsAttributeTest.php new file mode 100644 index 0000000000..e988483148 --- /dev/null +++ b/packages/view/tests/AsAttributeTest.php @@ -0,0 +1,206 @@ +render( + 'Test', + ); + + $this->assertSnippetsMatch('', $html); + } + + #[Test] + public function generic_element_expression_as_overrides_tag(): void + { + $html = TempestViewRenderer::make()->render( + view('Test')->data(tag: 'button'), + ); + + $this->assertSnippetsMatch('', $html); + } + + // ------------------------------------------------------------------------- + // GenericElement — as and :as on a nested element inside a parent
+ // ------------------------------------------------------------------------- + + #[Test] + public function generic_element_static_as_on_nested_element(): void + { + $html = TempestViewRenderer::make()->render( + '
Test
', + ); + + $this->assertSnippetsMatch('
', $html); + } + + #[Test] + public function generic_element_expression_as_on_nested_element(): void + { + $html = TempestViewRenderer::make()->render( + view('
Test
')->data(tag: 'button'), + ); + + $this->assertSnippetsMatch('
', $html); + } + + // ------------------------------------------------------------------------- + // ViewComponentElement — static `as` + // ------------------------------------------------------------------------- + + #[Test] + public function view_component_static_as_overrides_root_tag(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render( + 'Test', + ); + + $this->assertSnippetsMatch('', $html); + } + + // ------------------------------------------------------------------------- + // ViewComponentElement — expression `:as` with ternary and null-coalesce default + // ------------------------------------------------------------------------- + + #[Test] + public function view_component_expression_as_defaults_to_button_when_no_href(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render( + view('Test')->data(href: null), + ); + + $this->assertSnippetsMatch('', $html); + } + + #[Test] + public function view_component_expression_as_resolves_to_a_when_href_is_set(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render( + view('Test')->data(href: 'https://example.com'), + ); + + $this->assertSnippetsMatch('Test', $html); + } + + // ------------------------------------------------------------------------- + // ViewComponentElement — nested inside a plain GenericElement
+ // ------------------------------------------------------------------------- + + #[Test] + public function view_component_with_static_as_inside_generic_div(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render( + '
Test
', + ); + + $this->assertSnippetsMatch('
', $html); + } + + // ------------------------------------------------------------------------- + // ViewComponentElement — outer component without as, inner with static `as` + // ------------------------------------------------------------------------- + + #[Test] + public function view_component_without_as_wrapping_component_with_static_as(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render(<<<'HTML' + + Test + + HTML); + + $this->assertSnippetsMatch('
', $html); + } + + // ------------------------------------------------------------------------- + // ViewComponentElement — outer component without as, inner with expression `:as` + // ------------------------------------------------------------------------- + + #[Test] + public function view_component_without_as_wrapping_component_with_expression_as(): void + { + $renderer = $this->makeRenderer(); + + // $tag is null → falls back to 'button' + $html = $renderer->render( + view(<<<'HTML' + + Test + + HTML)->data(tag: null), + ); + + $this->assertSnippetsMatch('
', $html); + } + + #[Test] + public function view_component_without_as_wrapping_component_with_expression_as_resolved_to_a(): void + { + $renderer = $this->makeRenderer(); + + // $tag = 'a' → resolves to anchor + $html = $renderer->render( + view(<<<'HTML' + + Test + + HTML)->data(tag: 'a'), + ); + + $this->assertSnippetsMatch('
Test
', $html); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + 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), + ); + } +} diff --git a/packages/view/tests/Fixtures/x-link.view.php b/packages/view/tests/Fixtures/x-link.view.php new file mode 100644 index 0000000000..91db424ffa --- /dev/null +++ b/packages/view/tests/Fixtures/x-link.view.php @@ -0,0 +1 @@ + diff --git a/packages/view/tests/Fixtures/x-outer.view.php b/packages/view/tests/Fixtures/x-outer.view.php new file mode 100644 index 0000000000..3695a48a5a --- /dev/null +++ b/packages/view/tests/Fixtures/x-outer.view.php @@ -0,0 +1 @@ +
From 002e35cb5e39df5b3a2f2385efaa7e7a020db498 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Wed, 11 Mar 2026 13:12:33 +0000 Subject: [PATCH 3/4] docs(view): usage and instructions for AsAttribute --- docs/1-essentials/02-views.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/1-essentials/02-views.md b/docs/1-essentials/02-views.md index 17b6536ace..3db0f453cd 100644 --- a/docs/1-essentials/02-views.md +++ b/docs/1-essentials/02-views.md @@ -246,6 +246,39 @@ The example above will only render the child `div` elements:
Post C
``` +### 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 +My Link +``` +Would render +```html + +``` +The power behind this is when you use an `Expression` to determine the element. + +Say for example, you wish to have a `` component which renders as an `` when the `$href` attribute is provided. In your view, use the component like so: +```html +Click to go to an awesome website + +This is just a button +``` +In your `` component, define: +```html + +``` +Your page will render two links, as follows +```html +Click to go to an awesome website + + +``` + +#### Where this can and cannot be used + +You can't use the `as` Attribute on things like ``, ``, 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. From d2f1e83e4ea1cbd06e3dbfb28ac84bee8416f76c Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Wed, 11 Mar 2026 14:41:24 +0000 Subject: [PATCH 4/4] test(view): remove extra unused import, tidy-up --- packages/view/tests/AsAttributeTest.php | 39 ------------------------- 1 file changed, 39 deletions(-) diff --git a/packages/view/tests/AsAttributeTest.php b/packages/view/tests/AsAttributeTest.php index e988483148..0e6ebfca72 100644 --- a/packages/view/tests/AsAttributeTest.php +++ b/packages/view/tests/AsAttributeTest.php @@ -7,21 +7,12 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Tempest\View\Renderers\TempestViewRenderer; -use Tempest\View\ViewComponent; use Tempest\View\ViewConfig; use function Tempest\View\view; -/** - * Tests for the `as` and `:as` attribute, which overrides the root tag of - * any HTML element or view component at the call site. - */ final class AsAttributeTest extends TestCase { - // ------------------------------------------------------------------------- - // GenericElement — static `as` - // ------------------------------------------------------------------------- - #[Test] public function generic_element_static_as_overrides_tag(): void { @@ -42,10 +33,6 @@ public function generic_element_expression_as_overrides_tag(): void $this->assertSnippetsMatch('', $html); } - // ------------------------------------------------------------------------- - // GenericElement — as and :as on a nested element inside a parent
- // ------------------------------------------------------------------------- - #[Test] public function generic_element_static_as_on_nested_element(): void { @@ -66,10 +53,6 @@ public function generic_element_expression_as_on_nested_element(): void $this->assertSnippetsMatch('
', $html); } - // ------------------------------------------------------------------------- - // ViewComponentElement — static `as` - // ------------------------------------------------------------------------- - #[Test] public function view_component_static_as_overrides_root_tag(): void { @@ -82,10 +65,6 @@ public function view_component_static_as_overrides_root_tag(): void $this->assertSnippetsMatch('', $html); } - // ------------------------------------------------------------------------- - // ViewComponentElement — expression `:as` with ternary and null-coalesce default - // ------------------------------------------------------------------------- - #[Test] public function view_component_expression_as_defaults_to_button_when_no_href(): void { @@ -110,10 +89,6 @@ public function view_component_expression_as_resolves_to_a_when_href_is_set(): v $this->assertSnippetsMatch('Test', $html); } - // ------------------------------------------------------------------------- - // ViewComponentElement — nested inside a plain GenericElement
- // ------------------------------------------------------------------------- - #[Test] public function view_component_with_static_as_inside_generic_div(): void { @@ -126,10 +101,6 @@ public function view_component_with_static_as_inside_generic_div(): void $this->assertSnippetsMatch('
', $html); } - // ------------------------------------------------------------------------- - // ViewComponentElement — outer component without as, inner with static `as` - // ------------------------------------------------------------------------- - #[Test] public function view_component_without_as_wrapping_component_with_static_as(): void { @@ -144,16 +115,11 @@ public function view_component_without_as_wrapping_component_with_static_as(): v $this->assertSnippetsMatch('
', $html); } - // ------------------------------------------------------------------------- - // ViewComponentElement — outer component without as, inner with expression `:as` - // ------------------------------------------------------------------------- - #[Test] public function view_component_without_as_wrapping_component_with_expression_as(): void { $renderer = $this->makeRenderer(); - // $tag is null → falls back to 'button' $html = $renderer->render( view(<<<'HTML' @@ -170,7 +136,6 @@ public function view_component_without_as_wrapping_component_with_expression_as_ { $renderer = $this->makeRenderer(); - // $tag = 'a' → resolves to anchor $html = $renderer->render( view(<<<'HTML' @@ -182,10 +147,6 @@ public function view_component_without_as_wrapping_component_with_expression_as_ $this->assertSnippetsMatch('
Test
', $html); } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - private function makeRenderer(): TempestViewRenderer { $viewConfig = new ViewConfig()->addViewComponents(