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. 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) { diff --git a/packages/view/tests/AsAttributeTest.php b/packages/view/tests/AsAttributeTest.php new file mode 100644 index 0000000000..0e6ebfca72 --- /dev/null +++ b/packages/view/tests/AsAttributeTest.php @@ -0,0 +1,167 @@ +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); + } + + #[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); + } + + #[Test] + public function view_component_static_as_overrides_root_tag(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render( + 'Test', + ); + + $this->assertSnippetsMatch('', $html); + } + + #[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); + } + + #[Test] + public function view_component_with_static_as_inside_generic_div(): void + { + $renderer = $this->makeRenderer(); + + $html = $renderer->render( + '
Test
', + ); + + $this->assertSnippetsMatch('
', $html); + } + + #[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); + } + + #[Test] + public function view_component_without_as_wrapping_component_with_expression_as(): void + { + $renderer = $this->makeRenderer(); + + $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(); + + $html = $renderer->render( + view(<<<'HTML' + + Test + + HTML)->data(tag: 'a'), + ); + + $this->assertSnippetsMatch('
Test
', $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), + ); + } +} 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 @@ +