From 0272ea385fed84b2d96cf67f1fbfb7ab436c0a41 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Wed, 11 Mar 2026 08:31:40 +0000 Subject: [PATCH 01/12] test(view): add additional FallthroughAttribute tests --- .../view/tests/FallthroughAttributesTest.php | 29 +++++++++++++++++++ .../Fixtures/fallthrough-preamble.view.php | 7 +++++ ...fallthrough-preamble-dynamic-test.view.php | 6 ++++ .../x-fallthrough-preamble-test.view.php | 6 ++++ 4 files changed, 48 insertions(+) create mode 100644 packages/view/tests/Fixtures/fallthrough-preamble.view.php create mode 100644 packages/view/tests/Fixtures/x-fallthrough-preamble-dynamic-test.view.php create mode 100644 packages/view/tests/Fixtures/x-fallthrough-preamble-test.view.php diff --git a/packages/view/tests/FallthroughAttributesTest.php b/packages/view/tests/FallthroughAttributesTest.php index 89a6f84751..49ab8b7161 100644 --- a/packages/view/tests/FallthroughAttributesTest.php +++ b/packages/view/tests/FallthroughAttributesTest.php @@ -28,6 +28,35 @@ public function render(): void view(__DIR__ . '/Fixtures/fallthrough.view.php'), ); + lw(str_replace([' ', PHP_EOL], '', $html)); + + $this->assertEquals(str_replace([' ', PHP_EOL], '', <<<'HTML' +
+
+
+
+ HTML), str_replace([' ', PHP_EOL], '', $html)); + } + + #[Test] + public function render_with_preamble(): void + { + $viewConfig = new ViewConfig()->addViewComponents( + __DIR__ . '/Fixtures/x-fallthrough-preamble-test.view.php', + __DIR__ . '/Fixtures/x-fallthrough-preamble-dynamic-test.view.php', + ); + + $renderer = + TempestViewRenderer::make( + viewConfig: $viewConfig, + ); + + $html = $renderer->render( + view(__DIR__ . '/Fixtures/fallthrough-preamble.view.php'), + ); + + lw(str_replace([' ', PHP_EOL], '', $html)); + $this->assertEquals(str_replace([' ', PHP_EOL], '', <<<'HTML'
diff --git a/packages/view/tests/Fixtures/fallthrough-preamble.view.php b/packages/view/tests/Fixtures/fallthrough-preamble.view.php new file mode 100644 index 0000000000..34e4dc508f --- /dev/null +++ b/packages/view/tests/Fixtures/fallthrough-preamble.view.php @@ -0,0 +1,7 @@ + + + + diff --git a/packages/view/tests/Fixtures/x-fallthrough-preamble-dynamic-test.view.php b/packages/view/tests/Fixtures/x-fallthrough-preamble-dynamic-test.view.php new file mode 100644 index 0000000000..189ff210a1 --- /dev/null +++ b/packages/view/tests/Fixtures/x-fallthrough-preamble-dynamic-test.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-fallthrough-preamble-test.view.php b/packages/view/tests/Fixtures/x-fallthrough-preamble-test.view.php new file mode 100644 index 0000000000..c235f477d3 --- /dev/null +++ b/packages/view/tests/Fixtures/x-fallthrough-preamble-test.view.php @@ -0,0 +1,6 @@ + +
From 4eaf13ddde3d0872717ea7dd56afe807a66d9e05 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Mon, 30 Mar 2026 16:19:12 +0100 Subject: [PATCH 02/12] feat(view): resolve issue with fallthroughattributes from #2040, and add ApplyAttribute allowing developer control --- .../view/src/Attributes/ApplyAttribute.php | 64 +++++++++++ .../view/src/Attributes/AttributeFactory.php | 1 + .../src/Elements/ViewComponentElement.php | 103 +++++++++++++++--- 3 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 packages/view/src/Attributes/ApplyAttribute.php diff --git a/packages/view/src/Attributes/ApplyAttribute.php b/packages/view/src/Attributes/ApplyAttribute.php new file mode 100644 index 0000000000..ead72b024d --- /dev/null +++ b/packages/view/src/Attributes/ApplyAttribute.php @@ -0,0 +1,64 @@ +consumeAttribute(':apply'); + + if ($value === null || trim($value) === '') { + return $element; + } + + if ($element instanceof ViewComponentElement) { + $element->setApplyExpression($value); + + return $element; + } + + $element->addRawAttribute(sprintf( + '', + self::class, + $value, + )); + + return $element; + } + + /** + * Renders an ImmutableArray or plain array of attributes as an HTML attribute string. + * + * Boolean true emits a bare attribute name (e.g. `disabled`). + * Boolean false, null, and empty string are omitted entirely. + * All other values are rendered as name="value" pairs via ExpressionAttribute::render(). + * + * Returns a string with a leading space when not empty, or an empty string. + */ + public static function renderAll(ImmutableArray|array $attributes): string + { + if (is_array($attributes)) { + $attributes = new ImmutableArray($attributes); + } + + $parts = []; + + foreach ($attributes as $name => $value) { + $rendered = ExpressionAttribute::render((string) $name, $value); + + if ($rendered !== '') { + $parts[] = $rendered; + } + } + + return $parts === [] ? '' : ' ' . implode(' ', $parts); + } +} \ No newline at end of file diff --git a/packages/view/src/Attributes/AttributeFactory.php b/packages/view/src/Attributes/AttributeFactory.php index ce1f9eced5..05f5695733 100644 --- a/packages/view/src/Attributes/AttributeFactory.php +++ b/packages/view/src/Attributes/AttributeFactory.php @@ -17,6 +17,7 @@ public function make(string $attributeName): Attribute $attributeName === ':else' => new ElseAttribute(), $attributeName === ':foreach' => new ForeachAttribute(), $attributeName === ':forelse' => new ForelseAttribute(), + $attributeName === ':apply' => new ApplyAttribute(), $attributeName === 'as' => new AsAttribute('as'), $attributeName === ':as' => new AsAttribute(':as'), str_starts_with($attributeName, '::') => new EscapedExpressionAttribute($attributeName), diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index 191b22ed86..fbb7d54e4f 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -8,6 +8,7 @@ use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Str\ImmutableString; use Tempest\Support\Str\MutableString; +use Tempest\View\Attributes\ApplyAttribute; use Tempest\View\Element; use Tempest\View\Export\ViewObjectExporter; use Tempest\View\Parser\TempestViewCompiler; @@ -36,6 +37,9 @@ final class ViewComponentElement implements Element, WithToken private ?ImmutableArray $slots = null; + // Part of the implementation of the :apply attribute on ViewComponentElement + private ?string $applyExpression = null; + public function __construct( public readonly Token $token, private readonly Environment $environment, @@ -70,6 +74,32 @@ public function addVariable(string $name): self return $this; } + /** + * Called by ApplyAttribute when :apply="..." targets this element at the call site. + * Strips 'apply' from the attribute maps so it does not leak through as a component + * variable or appear in the exported $attributes array passed to the child. + */ + public function setApplyExpression(string $expression): void + { + $this->applyExpression = $expression; + + $filtered = []; + foreach ($this->viewComponentAttributes as $key => $value) { + if ($key !== 'apply') { + $filtered[$key] = $value; + } + } + $this->viewComponentAttributes = new ImmutableArray($filtered); + + $filtered = []; + foreach ($this->expressionAttributes as $key => $value) { + if ($key !== 'apply') { + $filtered[$key] = $value; + } + } + $this->expressionAttributes = new ImmutableArray($filtered); + } + public function getViewComponent(): ViewComponent { return $this->viewComponent; @@ -156,34 +186,49 @@ public function compile(): string private function compileComponent(): ImmutableString { + // If the component template itself uses :apply= anywhere, the developer is controlling + // attribute spreading manually. If :apply was set at the call site, all attributes are + // forwarded explicitly via the merged $attributes array. Either way, skip auto-fallthrough. + $skipFallthrough = str_contains($this->viewComponent->contents, ':apply=') + || $this->applyExpression !== null; + $tokens = TempestViewParser::ast($this->viewComponent->contents); $buffer = ''; - /** - * @var int $i - * @var Token $token - */ - foreach ($tokens as $i => $token) { - // Fallthrough attributes will be applied to the first element in the component. - $shouldApplyFallthrough = $i === 0 && $token->type === TokenType::OPEN_TAG_START && $token->tag !== 'x-slot'; + // Tracks whether we have already applied fallthrough to the first valid target, + // replacing the old $i === 0 index check which broke whenever a PHP preamble, + // whitespace token, comment, or doctype preceded the first HTML element. + $fallthroughApplied = false; - if (! $shouldApplyFallthrough) { - // Anything else is is compiled like normal - $buffer .= $this->compileTokens( - tokens: [$token], - ); + foreach ($tokens as $token) { + // A valid fallthrough target is the first real HTML open or self-closing tag + // that is not x-slot. SELF_CLOSING_TAG covers bare no-attribute forms like
. + // OPEN_TAG_START covers everything else, including
. + $shouldApplyFallthrough = ! $fallthroughApplied + && ! $skipFallthrough + && in_array($token->type, [TokenType::OPEN_TAG_START, TokenType::SELF_CLOSING_TAG], true) + && $token->tag !== 'x-slot'; + if (! $shouldApplyFallthrough) { + $buffer .= $this->compileTokens(tokens: [$token]); continue; } + $fallthroughApplied = true; + $attributes = arr($token->htmlAttributes) ->map(fn (string $value) => new MutableString($value)); - // class, style, and id are fallthrough attributes - $attributes = $this->applyFallthroughAttribute($attributes, 'class'); - $attributes = $this->applyFallthroughAttribute($attributes, 'style'); - $attributes = $this->applyFallthroughAttribute($attributes, 'id'); + foreach (['class', 'style', 'id'] as $name) { + // If the root element already declares this attribute — in plain form (class="...") + // or expression form (:class="...") — leave the developer's logic untouched. + if (array_key_exists($name, $token->htmlAttributes) || array_key_exists(':' . $name, $token->htmlAttributes)) { + continue; + } + + $attributes = $this->applyFallthroughAttribute($attributes, $name); + } $attributeString = $attributes ->map(fn (MutableString $value, string $key) => sprintf('%s="%s"', $key, $value->trim())) @@ -193,6 +238,14 @@ private function compileComponent(): ImmutableString fn (ImmutableString $s) => $s->prepend(' '), ); + if ($token->type === TokenType::SELF_CLOSING_TAG) { + // Bare self-closing token with no separate attribute tokens (e.g.
). + // Reconstruct as a self-closing tag with the merged attributes. + $buffer .= sprintf('<%s%s />', $token->tag, $attributeString); + continue; + } + + // OPEN_TAG_START — identical to the original behaviour. $buffer .= sprintf('<%s%s>', $token->tag, $attributeString); $buffer .= $this->compileTokens( @@ -376,7 +429,21 @@ private function exportAttributesArray(): string : sprintf("'%s' => %s", $key, ViewObjectExporter::exportValue($value)); } - return sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries)); + $baseArray = sprintf('new \%s([%s])', ImmutableArray::class, implode(', ', $entries)); + + if ($this->applyExpression !== null) { + // Merge the apply expression over the base attribute array at runtime. + // Right-side wins on key collisions — the passed ImmutableArray or plain array + // overwrites any matching key already in the base. iterator_to_array handles both. + return sprintf( + 'new \%1$s(array_replace(iterator_to_array(%2$s), is_array(%3$s) ? %3$s : iterator_to_array(%3$s)))', + ImmutableArray::class, + $baseArray, + $this->applyExpression, + ); + } + + return $baseArray; } public function getImports(): array @@ -397,4 +464,4 @@ public function getImports(): array return $imports; } -} +} \ No newline at end of file From 303bed88f058b92824f14a822c51b68bd5f68c2a Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Mon, 30 Mar 2026 16:19:35 +0100 Subject: [PATCH 03/12] tests(view): updated and expanded FallthroughAttributes tests --- .../view/tests/FallthroughAttributesTest.php | 245 +++++++++++++++--- .../view/tests/Fixtures/x-ft-all.view.php | 6 + .../view/tests/Fixtures/x-ft-apply.view.php | 6 + .../tests/Fixtures/x-ft-class-style.view.php | 6 + .../view/tests/Fixtures/x-ft-class.view.php | 6 + packages/view/tests/Fixtures/x-ft-id.view.php | 6 + .../view/tests/Fixtures/x-ft-none.view.php | 6 + .../view/tests/Fixtures/x-ft-style.view.php | 6 + 8 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 packages/view/tests/Fixtures/x-ft-all.view.php create mode 100644 packages/view/tests/Fixtures/x-ft-apply.view.php create mode 100644 packages/view/tests/Fixtures/x-ft-class-style.view.php create mode 100644 packages/view/tests/Fixtures/x-ft-class.view.php create mode 100644 packages/view/tests/Fixtures/x-ft-id.view.php create mode 100644 packages/view/tests/Fixtures/x-ft-none.view.php create mode 100644 packages/view/tests/Fixtures/x-ft-style.view.php diff --git a/packages/view/tests/FallthroughAttributesTest.php b/packages/view/tests/FallthroughAttributesTest.php index 49ab8b7161..4abe45fb28 100644 --- a/packages/view/tests/FallthroughAttributesTest.php +++ b/packages/view/tests/FallthroughAttributesTest.php @@ -11,57 +11,234 @@ final class FallthroughAttributesTest extends TestCase { + private function strip(string $html): string + { + return str_replace([' ', PHP_EOL], '', $html); + } + + private function makeRenderer(string ...$fixtures): TempestViewRenderer + { + $config = new ViewConfig(); + + foreach ($fixtures as $fixture) { + $config->addViewComponents(__DIR__ . '/Fixtures/' . $fixture); + } + + return TempestViewRenderer::make(viewConfig: $config); + } + + // Single attribute: class + + #[Test] + public function class_falls_through_when_not_defined_on_root(): void + { + $renderer = $this->makeRenderer('x-ft-none.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + #[Test] - public function render(): void + public function class_is_blocked_when_defined_on_root(): void { - $viewConfig = new ViewConfig()->addViewComponents( - __DIR__ . '/Fixtures/x-fallthrough-test.view.php', - __DIR__ . '/Fixtures/x-fallthrough-dynamic-test.view.php', + $renderer = $this->makeRenderer('x-ft-class.view.php'); + + $html = $renderer->render(''); + + // Root declares class="base-class"; parent's class backs off entirely. + $this->assertEquals( + $this->strip('
'), + $this->strip($html), ); + } - $renderer = - TempestViewRenderer::make( - viewConfig: $viewConfig, - ); + // Single attribute: style - $html = $renderer->render( - view(__DIR__ . '/Fixtures/fallthrough.view.php'), + #[Test] + public function style_falls_through_when_not_defined_on_root(): void + { + $renderer = $this->makeRenderer('x-ft-none.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), ); + } + + #[Test] + public function style_is_blocked_when_defined_on_root(): void + { + $renderer = $this->makeRenderer('x-ft-style.view.php'); - lw(str_replace([' ', PHP_EOL], '', $html)); + $html = $renderer->render(''); - $this->assertEquals(str_replace([' ', PHP_EOL], '', <<<'HTML' -
-
-
-
- HTML), str_replace([' ', PHP_EOL], '', $html)); + // Root declares style="font-weight:bold;"; parent's style backs off. + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); } + // Single attribute: id + #[Test] - public function render_with_preamble(): void + public function id_falls_through_when_not_defined_on_root(): void { - $viewConfig = new ViewConfig()->addViewComponents( - __DIR__ . '/Fixtures/x-fallthrough-preamble-test.view.php', - __DIR__ . '/Fixtures/x-fallthrough-preamble-dynamic-test.view.php', + $renderer = $this->makeRenderer('x-ft-none.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), ); + } - $renderer = - TempestViewRenderer::make( - viewConfig: $viewConfig, - ); + #[Test] + public function id_is_blocked_when_defined_on_root(): void + { + $renderer = $this->makeRenderer('x-ft-id.view.php'); - $html = $renderer->render( - view(__DIR__ . '/Fixtures/fallthrough-preamble.view.php'), + $html = $renderer->render(''); + + // Root declares id="base-id"; parent's id backs off. + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + // Two-of-three: one blocked, one falls through + // + // Each test passes two of the three fallthrough attributes from the parent. + // The component defines one of those two on its root, so only the other falls through. + + #[Test] + public function style_is_blocked_and_id_falls_through(): void + { + // x-ft-style root: style="font-weight:bold;" — blocks style, id is absent → falls through + $renderer = $this->makeRenderer('x-ft-style.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + #[Test] + public function class_is_blocked_and_style_falls_through(): void + { + // x-ft-class root: class="base-class" — blocks class, style is absent → falls through + $renderer = $this->makeRenderer('x-ft-class.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + #[Test] + public function id_is_blocked_and_class_falls_through(): void + { + // x-ft-id root: id="base-id" — blocks id, class is absent → falls through. + // Attribute order: root attrs come first (id), then appended fallthrough (class). + $renderer = $this->makeRenderer('x-ft-id.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + // All three passed at once: varying root definitions + + #[Test] + public function all_three_fall_through_when_none_defined_on_root(): void + { + $renderer = $this->makeRenderer('x-ft-none.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + #[Test] + public function class_blocked_when_all_three_passed(): void + { + // x-ft-class root: class="base-class" — class blocked, style and id fall through + $renderer = $this->makeRenderer('x-ft-class.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + #[Test] + public function class_and_style_blocked_when_all_three_passed(): void + { + // x-ft-class-style root: class and style both defined — both blocked, id falls through + $renderer = $this->makeRenderer('x-ft-class-style.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); + } + + #[Test] + public function all_three_blocked_when_all_defined_on_root(): void + { + // x-ft-all root: class, style, and id all defined — nothing from parent applied + $renderer = $this->makeRenderer('x-ft-all.view.php'); + + $html = $renderer->render(''); + + $this->assertEquals( + $this->strip('
'), + $this->strip($html), ); + } + + // :apply + // + // :apply="$attributes" inside the component template opts out of auto-fallthrough + // and spreads the full $attributes ImmutableArray onto the targeted element. + // This includes attributes that are not part of the auto-fallthrough set (width, height). - lw(str_replace([' ', PHP_EOL], '', $html)); + #[Test] + public function apply_spreads_all_attributes_including_non_fallthrough_attrs(): void + { + // x-ft-apply template:
+ // All five parent attributes are in $attributes and spread onto the div. + $renderer = $this->makeRenderer('x-ft-apply.view.php'); + + $html = $renderer->render( + '', + ); - $this->assertEquals(str_replace([' ', PHP_EOL], '', <<<'HTML' -
-
-
-
- HTML), str_replace([' ', PHP_EOL], '', $html)); + $this->assertEquals( + $this->strip('
'), + $this->strip($html), + ); } } diff --git a/packages/view/tests/Fixtures/x-ft-all.view.php b/packages/view/tests/Fixtures/x-ft-all.view.php new file mode 100644 index 0000000000..e24250698e --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-all.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-ft-apply.view.php b/packages/view/tests/Fixtures/x-ft-apply.view.php new file mode 100644 index 0000000000..f7278efa99 --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-apply.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-ft-class-style.view.php b/packages/view/tests/Fixtures/x-ft-class-style.view.php new file mode 100644 index 0000000000..c093acef8b --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-class-style.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-ft-class.view.php b/packages/view/tests/Fixtures/x-ft-class.view.php new file mode 100644 index 0000000000..9731646bc0 --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-class.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-ft-id.view.php b/packages/view/tests/Fixtures/x-ft-id.view.php new file mode 100644 index 0000000000..1b40fb8e5e --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-id.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-ft-none.view.php b/packages/view/tests/Fixtures/x-ft-none.view.php new file mode 100644 index 0000000000..0c9c690ed2 --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-none.view.php @@ -0,0 +1,6 @@ + +
diff --git a/packages/view/tests/Fixtures/x-ft-style.view.php b/packages/view/tests/Fixtures/x-ft-style.view.php new file mode 100644 index 0000000000..b50e4aee0c --- /dev/null +++ b/packages/view/tests/Fixtures/x-ft-style.view.php @@ -0,0 +1,6 @@ + +
From fdb3a05833ed7aa77d9d2abee4d23dfcef4c9e7f Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Mon, 30 Mar 2026 20:29:13 +0100 Subject: [PATCH 04/12] feat(view): removed extra space, tweaked stringify rules --- .../view/src/Attributes/ApplyAttribute.php | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/view/src/Attributes/ApplyAttribute.php b/packages/view/src/Attributes/ApplyAttribute.php index ead72b024d..beebe0fe3f 100644 --- a/packages/view/src/Attributes/ApplyAttribute.php +++ b/packages/view/src/Attributes/ApplyAttribute.php @@ -9,6 +9,8 @@ use Tempest\View\Element; use Tempest\View\Elements\ViewComponentElement; +use function Tempest\Support\str; + final readonly class ApplyAttribute implements Attribute { public function apply(Element $element): Element @@ -35,13 +37,24 @@ public function apply(Element $element): Element } /** - * Renders an ImmutableArray or plain array of attributes as an HTML attribute string. + * Stringifies an ImmutableArray or plain array of attributes into an HTML attribute string. + * + * Rules: + * - boolean true → bare attribute name (e.g. `disabled`) + * - boolean false → omitted + * - null → omitted + * - empty string → omitted + * - int / float → name="value" (cast to string — HTML attributes are always strings) + * - string → name="value" + * - array → name="space-joined values" (via ExpressionAttribute::resolveValue) * - * Boolean true emits a bare attribute name (e.g. `disabled`). - * Boolean false, null, and empty string are omitted entirely. - * All other values are rendered as name="value" pairs via ExpressionAttribute::render(). + * Returns 'key="val" key2="val2"' with NO leading space. GenericElement's compile() + * inserts one space before the raw attribute block at compile time, so adding a leading + * space here would produce a double space in the rendered output. * - * Returns a string with a leading space when not empty, or an empty string. + * Note: when this returns '' (all attributes omitted), GenericElement's compile-time + * space still appears, producing e.g. ` +``` +Now, in your page, you may utilise the element: +```html index.view.php + +``` +As these attributes automatically apply, your button will be converted to this: +```html + +``` +When you use this version of ``: +- `{html}id` will now default to `mybtn_(sequence generated by uniqid)`, +- `{html}style` will not appear automatically, as it was not supplied, +- `{html}class` will have a default, you can of course instead concatenate these strings, or use a CVA utility for smart class merging, or anything you want. + +For example, pass one or more classes: +```html + +``` +And you'll get +```html + +``` + +### Controlling fallthrough attributes with the Apply attribute + +You can also leverage the `ApplyAttribute` to completely control the behaviour, and add further fallthrough attributes, if you wish. When `:apply` is detected on a view component, Tempest will disable all automatic fallthrough attributes, for that instance of the view component. If you are familiar with JS frontend frameworks, this is not dissimilar to a one-way `v-bind` in Vue, or a spread props operator in other similar languages. + +By default, `$attributes` is an `ImmutableArray` and so we can manipulate it with the methods available on that class. + +:::info +You cannot mix `ApplyAttribute` with automatic fallthrough attributes. Opting to use the `ApplyAttribute` hands you full control of which attributes are applied, which means you then need to declare these. +::: + +#### Excluding specific fallthrough attributes + +To exclude specific attributes from falling through, configure your `button` view component like this: +```html x-button.view.php + ``` +:::info +Why array_flip? In `$attributes` the keys are the attributes, in the array in the example above, the values are the attributes, you could also pass `['class' => 0, 'width' => 1, etc]` without a flip. +::: +Now, when utilising it in your page: +```html index.view.php + +``` +Will result in: +```html + +``` +:::info +Why array_flip? In `$attributes` the keys are the attributes, in the array in the example above, the values are the attributes, you could also pass `['class' => 0, 'width' => 1, etc]` without a flip. +::: +Now, when utilising it in your page: ```html index.view.php - + ``` +Tempest will apply only the specified attributes: +```html + +``` +Now, when utilising it in your page: +```html index.view.php + +``` +Tempest will spread the supplied attributes, and as we also used the `AsAttribute` to convert it to a `{html}a` when `$href` is populated, you will get a hyperlink: +```html +Tempest, the framework that gets out of your way +``` ### Dynamic attributes diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index e39194e2c8..5c43aaa957 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -621,6 +621,7 @@ public function test_attributes_variable_in_view_component(): void public function test_fallthrough_attributes(): void { + // Root has no class/style/id — all three fall through from the call site. $this->view->registerViewComponent('x-test', <<<'HTML'
HTML); @@ -636,6 +637,8 @@ public function test_fallthrough_attributes(): void public function test_merged_fallthrough_attributes(): void { + // Root defines all three of class, style, and id. None of the parent values + // are applied — the component's own declarations are left untouched. $this->view->registerViewComponent('x-test', <<<'HTML'
HTML); @@ -645,12 +648,15 @@ public function test_merged_fallthrough_attributes(): void HTML); $this->assertSnippetsMatch(<<<'HTML' -
+
HTML, $html); } public function test_fallthrough_attributes_with_other_attributes(): void { + // Root defines all three of class, style, and id. None of the parent values + // are applied — the component's own declarations are left untouched. + // Use :apply inside the component template if you need to merge manually. $this->view->registerViewComponent('x-test', <<<'HTML'
HTML); @@ -660,7 +666,7 @@ public function test_fallthrough_attributes_with_other_attributes(): void HTML); $this->assertSnippetsMatch(<<<'HTML' -
+
HTML, $html); } @@ -697,21 +703,6 @@ public function test_merge_class(): void HTML, $html); } - public function test_merge_class_from_template_to_component(): void - { - $this->view->registerViewComponent('x-test', <<<'HTML' -
- HTML); - - $html = $this->view->render(<<<'HTML' - - HTML); - - $this->assertSnippetsMatch(<<<'HTML' -
- HTML, $html); - } - public function test_does_not_duplicate_br(): void { $this->view->registerViewComponent('x-html-base', <<<'HTML' From dfbb2f97ed26cd2a4f09ba34239ba367e930696f Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Mon, 30 Mar 2026 20:54:26 +0100 Subject: [PATCH 07/12] docs(view): docs update for fallthrough attributes and ApplyAttribute --- docs/1-essentials/02-views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/1-essentials/02-views.md b/docs/1-essentials/02-views.md index 43db12676e..566c9e7665 100644 --- a/docs/1-essentials/02-views.md +++ b/docs/1-essentials/02-views.md @@ -392,7 +392,7 @@ And you'll get ### Controlling fallthrough attributes with the Apply attribute -You can also leverage the `ApplyAttribute` to completely control the behaviour, and add further fallthrough attributes, if you wish. When `:apply` is detected on a view component, Tempest will disable all automatic fallthrough attributes, for that instance of the view component. If you are familiar with JS frontend frameworks, this is not dissimilar to a one-way `v-bind` in Vue, or a spread props operator in other similar languages. +You can also leverage the `ApplyAttribute` to completely control the behaviour, and add further fallthrough attributes, if you wish. When `:apply` is detected on a view component, Tempest will disable all automatic fallthrough attributes, for that instance of the view component. If you are familiar with JS frontend frameworks, this is not dissimilar to a one-way `v-bind` in Vue, or a spread props operator in other languages. By default, `$attributes` is an `ImmutableArray` and so we can manipulate it with the methods available on that class. From df1d4d44c3df4110dfc85bc9f854f71c66851ff3 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Mon, 30 Mar 2026 21:39:50 +0100 Subject: [PATCH 08/12] chore(view): mago fmt --- packages/view/src/Attributes/ApplyAttribute.php | 2 +- packages/view/src/Elements/ViewComponentElement.php | 11 ++++------- packages/view/tests/FallthroughAttributesTest.php | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/view/src/Attributes/ApplyAttribute.php b/packages/view/src/Attributes/ApplyAttribute.php index 1404bdbdca..bd9196f40f 100644 --- a/packages/view/src/Attributes/ApplyAttribute.php +++ b/packages/view/src/Attributes/ApplyAttribute.php @@ -85,4 +85,4 @@ public static function renderAll(ImmutableArray|array $attributes): string return implode(' ', $parts); } -} \ No newline at end of file +} diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index fbb7d54e4f..e5b33e8c3a 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -189,8 +189,7 @@ private function compileComponent(): ImmutableString // If the component template itself uses :apply= anywhere, the developer is controlling // attribute spreading manually. If :apply was set at the call site, all attributes are // forwarded explicitly via the merged $attributes array. Either way, skip auto-fallthrough. - $skipFallthrough = str_contains($this->viewComponent->contents, ':apply=') - || $this->applyExpression !== null; + $skipFallthrough = str_contains($this->viewComponent->contents, ':apply=') || $this->applyExpression !== null; $tokens = TempestViewParser::ast($this->viewComponent->contents); @@ -205,10 +204,8 @@ private function compileComponent(): ImmutableString // A valid fallthrough target is the first real HTML open or self-closing tag // that is not x-slot. SELF_CLOSING_TAG covers bare no-attribute forms like
. // OPEN_TAG_START covers everything else, including
. - $shouldApplyFallthrough = ! $fallthroughApplied - && ! $skipFallthrough - && in_array($token->type, [TokenType::OPEN_TAG_START, TokenType::SELF_CLOSING_TAG], true) - && $token->tag !== 'x-slot'; + $shouldApplyFallthrough = + ! $fallthroughApplied && ! $skipFallthrough && in_array($token->type, [TokenType::OPEN_TAG_START, TokenType::SELF_CLOSING_TAG], true) && $token->tag !== 'x-slot'; if (! $shouldApplyFallthrough) { $buffer .= $this->compileTokens(tokens: [$token]); @@ -464,4 +461,4 @@ public function getImports(): array return $imports; } -} \ No newline at end of file +} diff --git a/packages/view/tests/FallthroughAttributesTest.php b/packages/view/tests/FallthroughAttributesTest.php index 4abe45fb28..075d5ec897 100644 --- a/packages/view/tests/FallthroughAttributesTest.php +++ b/packages/view/tests/FallthroughAttributesTest.php @@ -219,7 +219,7 @@ public function all_three_blocked_when_all_defined_on_root(): void ); } - // :apply + // :apply // // :apply="$attributes" inside the component template opts out of auto-fallthrough // and spreads the full $attributes ImmutableArray onto the targeted element. From cbefb7a9655110a3951855a862d982594ce3d84f Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Tue, 31 Mar 2026 13:06:13 +0100 Subject: [PATCH 09/12] docs(view): update for with and without methods introduced in #2094 --- docs/1-essentials/02-views.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/1-essentials/02-views.md b/docs/1-essentials/02-views.md index 566c9e7665..e56dec8b11 100644 --- a/docs/1-essentials/02-views.md +++ b/docs/1-essentials/02-views.md @@ -404,7 +404,7 @@ You cannot mix `ApplyAttribute` with automatic fallthrough attributes. Opting to To exclude specific attributes from falling through, configure your `button` view component like this: ```html x-button.view.php - ``` @@ -424,7 +424,7 @@ Will result in: To include only specific attributes, configure your `button` view component like this: ```html x-button.view.php - ``` From fe7132ccf212681e261823b417189921763d4af2 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Tue, 31 Mar 2026 13:11:57 +0100 Subject: [PATCH 10/12] refactor(view): remove unused import --- packages/view/tests/FallthroughAttributesTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/view/tests/FallthroughAttributesTest.php b/packages/view/tests/FallthroughAttributesTest.php index 075d5ec897..f5fee146cc 100644 --- a/packages/view/tests/FallthroughAttributesTest.php +++ b/packages/view/tests/FallthroughAttributesTest.php @@ -7,8 +7,6 @@ use Tempest\View\Renderers\TempestViewRenderer; use Tempest\View\ViewConfig; -use function Tempest\View\view; - final class FallthroughAttributesTest extends TestCase { private function strip(string $html): string From 4573df8e2aa7f2aa5816599736c80d038675701d Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Tue, 31 Mar 2026 13:36:27 +0100 Subject: [PATCH 11/12] refactor(view): rector rule performed refactor --- packages/view/src/Attributes/ApplyAttribute.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/view/src/Attributes/ApplyAttribute.php b/packages/view/src/Attributes/ApplyAttribute.php index bd9196f40f..0b700c4346 100644 --- a/packages/view/src/Attributes/ApplyAttribute.php +++ b/packages/view/src/Attributes/ApplyAttribute.php @@ -71,8 +71,13 @@ public static function renderAll(ImmutableArray|array $attributes): string $parts[] = $attrName; continue; } - - if ($value === false || $value === null || $value === '') { + if ($value === false) { + continue; + } + if ($value === null) { + continue; + } + if ($value === '') { continue; } From 5b85298f0278d8eebbe8082fe684bb2c62cf856a Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Tue, 31 Mar 2026 13:38:51 +0100 Subject: [PATCH 12/12] refactor(view): moved from str_contains to ast search --- .../src/Elements/ViewComponentElement.php | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/view/src/Elements/ViewComponentElement.php b/packages/view/src/Elements/ViewComponentElement.php index e5b33e8c3a..d542d50fbf 100644 --- a/packages/view/src/Elements/ViewComponentElement.php +++ b/packages/view/src/Elements/ViewComponentElement.php @@ -85,18 +85,24 @@ public function setApplyExpression(string $expression): void $filtered = []; foreach ($this->viewComponentAttributes as $key => $value) { - if ($key !== 'apply') { - $filtered[$key] = $value; + if ($key === 'apply') { + continue; } + + $filtered[$key] = $value; } + $this->viewComponentAttributes = new ImmutableArray($filtered); $filtered = []; foreach ($this->expressionAttributes as $key => $value) { - if ($key !== 'apply') { - $filtered[$key] = $value; + if ($key === 'apply') { + continue; } + + $filtered[$key] = $value; } + $this->expressionAttributes = new ImmutableArray($filtered); } @@ -108,7 +114,7 @@ public function getViewComponent(): ViewComponent /** @return ImmutableArray */ public function getSlots(): ImmutableArray { - if ($this->slots !== null) { + if ($this->slots instanceof ImmutableArray) { return $this->slots; } @@ -186,12 +192,22 @@ public function compile(): string private function compileComponent(): ImmutableString { - // If the component template itself uses :apply= anywhere, the developer is controlling + $tokens = TempestViewParser::ast($this->viewComponent->contents); + + $containsApply = static function (iterable $tokens) use (&$containsApply): bool { + foreach ($tokens as $token) { + if (array_key_exists(':apply', $token->htmlAttributes) || $containsApply($token->children)) { + return true; + } + } + + return false; + }; + + // If the component template itself uses :apply on any element, the developer is controlling // attribute spreading manually. If :apply was set at the call site, all attributes are // forwarded explicitly via the merged $attributes array. Either way, skip auto-fallthrough. - $skipFallthrough = str_contains($this->viewComponent->contents, ':apply=') || $this->applyExpression !== null; - - $tokens = TempestViewParser::ast($this->viewComponent->contents); + $skipFallthrough = $this->applyExpression !== null || $containsApply($tokens); $buffer = ''; @@ -220,10 +236,12 @@ private function compileComponent(): ImmutableString foreach (['class', 'style', 'id'] as $name) { // If the root element already declares this attribute — in plain form (class="...") // or expression form (:class="...") — leave the developer's logic untouched. - if (array_key_exists($name, $token->htmlAttributes) || array_key_exists(':' . $name, $token->htmlAttributes)) { + if (array_key_exists($name, $token->htmlAttributes)) { + continue; + } + if (array_key_exists(':' . $name, $token->htmlAttributes)) { continue; } - $attributes = $this->applyFallthroughAttribute($attributes, $name); } @@ -262,7 +280,7 @@ private function compileTokens(iterable $tokens, ?Token $parentToken = null): st { $buffer = ''; - $parentIsComponent = $parentToken !== null && $this->isViewComponentToken($parentToken); + $parentIsComponent = $parentToken instanceof Token && $this->isViewComponentToken($parentToken); foreach ($tokens as $token) { if ($token->tag === 'x-slot') {