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
117 changes: 112 additions & 5 deletions docs/1-essentials/02-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,21 +341,128 @@ The idiomatic way of using attributes is to always use `{txt}kebab-case`.

### Fallthrough attributes

When `{html}class` and `{html}style` attributes are used on a view component, they will automatically be added to the root node, or merged with the existing attribute if it already exists.
When `{html}class` and `{html}style` attributes, or `{html}id` is provided on a view component, Tempest will attempt to automatically apply these to the root node within the view component.

:::info
In previous releases (3.8.0 and prior), Tempest would attempt to *merge* these values, however there was no way to prevent this, or customise the behaviour. There was also a bug in applying the attributes, which meant that in many cases it didn't apply at all, resulting in inconsistent behaviour. This has been resolved, but has a new default behaviour, as explained below.
:::

Assume you have a `button`, like so, with a default set of classes present:
```html x-button.view.php
<button class="rounded-md px-2.5 py-1.5 text-sm">
<!-- ... -->
<x-slot />
</button>
```
Now, in your page, you may utilise the element:
```html index.view.php
<x-button id="myBtn" style="color: red;" />
```
As these attributes automatically apply, your button will be converted to this:
```html
<button id="myBtn" style="color: red;" class="rounded-md px-2.5 py-1.5 text-sm" />
```

#### Disabling automatic fallthrough

Tempest will attempt to apply `{html}class`, `{html}style`, and `{html}id` automatically, when they are passed to a view component. For example:
```html index.view.php
<x-button id="myBtn" style="color: red;" />
```
With the above, Tempest will attempt to apply `{html}style`, and `{html}id` automatically. As `{html}class` isn't configured, it isn't applied.

In the view component itself, you can configure `{html}class`, `{html}style`, and `{html}id` to anything you want, and Tempest will not overwrite them. You can of course, also then use these classes however you want to use them:
```html x-button.view.php
<button :id="uniqid(($id ?? 'mybtn') . '_')" :class="$class ?? 'rounded-md px-2.5 py-1.5 text-sm'">
<x-slot />
</button>
```
When you use this version of `<x-button />`:
- `{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
<x-button id="myBtn" class="override" />
```
And you'll get
```html
<button class="override" id="myBtn_69cad27787c20"></button>
```

### 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 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
<button :apply="$attributes->without(['id', 'style'])">
<x-slot />
</button>
```
:::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
<x-button id="myBtn" style="color: red;" class="rounded-md px-2.5 py-1.5 text-sm" />
```
Will result in:
```html
<button class="rounded-md px-2.5 py-1.5 text-sm" />
```

The example above defines a button component with a default set of classes. Using this component and providing another set of classes will merge them together:
#### Including only specific fallthrough attributes

To include only specific attributes, configure your `button` view component like this:
```html x-button.view.php
<button :apply="$attributes->with(['class', 'width', 'height'])">
<x-slot />
</button>
```
:::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
<x-button class="text-gray-100 bg-gray-900" />
<x-button id="myBtn" style="color: red;" class="rounded-md px-2.5 py-1.5 text-sm" width="1em" height="1em" />
```
Tempest will apply only the specified attributes:
```html
<button class="rounded-md px-2.5 py-1.5 text-sm" />
```

#### Advanced usage of the Apply attribute

As the `ApplyAttribute` simply stringifies string and boolean values from the provided array, you can build the array however you like.

Similarly, the `id` attribute will always replace an existing `id` attribute on the root node of a view component.
Consider this example `button`:
```php x-button.view.php
<?php
$apply = [
'class' => $class ?? null,
'href' => $href ?? '',
'target' => (isset($href) && str_contains($href, 'http')) ? '_blank' : null,
];
?>
<button :as="$apply['href'] !== '' ? 'a' : 'button'" :apply="$apply">{{ $label ?? '' }}</button>
```
Now, when utilising it in your page:
```html index.view.php
<x-button href="https://tempestphp.com" label="Tempest, the framework that gets out of your way" />
```
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
<a href="https://www.tempestphp.com" target="_blank">Tempest, the framework that gets out of your way</a>
```

### Dynamic attributes

Expand Down
93 changes: 93 additions & 0 deletions packages/view/src/Attributes/ApplyAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace Tempest\View\Attributes;

use Tempest\Support\Arr\ImmutableArray;
use Tempest\View\Attribute;
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
{
$value = $element->consumeAttribute(':apply');

if ($value === null || trim($value) === '') {
return $element;
}

if ($element instanceof ViewComponentElement) {
$element->setApplyExpression($value);

return $element;
}

$element->addRawAttribute(sprintf(
'<?= \%s::renderAll(%s) ?>',
self::class,
$value,
));

return $element;
}

/**
* 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)
*
* 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.
*
* Note: when this returns '' (all attributes omitted), GenericElement's compile-time
* space still appears, producing e.g. `<button >`. This is pre-existing framework
* behaviour shared with ExpressionAttribute and cannot be fixed in this class alone.
*/
public static function renderAll(ImmutableArray|array $attributes): string
{
if (is_array($attributes)) {
$attributes = new ImmutableArray($attributes);
}

$parts = [];

foreach ($attributes as $name => $value) {
$attrName = str($name)->kebab()->toString();

if ($value === true) {
$parts[] = $attrName;
continue;
}
if ($value === false) {
continue;
}
if ($value === null) {
continue;
}
if ($value === '') {
continue;
}

$resolved = ExpressionAttribute::resolveValue($value);

if ($resolved !== '') {
$parts[] = sprintf('%s="%s"', $attrName, $resolved);
}
}

return implode(' ', $parts);
}
}
1 change: 1 addition & 0 deletions packages/view/src/Attributes/AttributeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading