Skip to content

Commit 3c800d8

Browse files
committed
[Twig][Live] add html attributes system
- adds `HasAttributesTrait` for components which makes `attributes` variable available in component templates - adds `PostMount` component hook to intercept extra props - adds `PreRenderEvent` to intercept/manipulate twig template/variables before rendering - [BC BREAK] Remove `init_live_component()` twig function, use `{{ attributes }}` instead.
1 parent 112ed03 commit 3c800d8

40 files changed

+987
-195
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
- Send live action arguments to backend
99

10+
- [BC BREAK] Remove `init_live_component()` twig function, use `{{ attributes }}` instead:
11+
```diff
12+
- <div {{ init_live_component() }}>
13+
+ <div {{ attributes }}>
14+
```
15+
1016
## 2.0.0
1117

1218
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`

src/LiveComponent/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
},
2828
"require": {
2929
"php": ">=8.0",
30-
"symfony/ux-twig-component": "^2.0"
30+
"symfony/ux-twig-component": "^2.1"
3131
},
3232
"require-dev": {
3333
"symfony/framework-bundle": "^4.4|^5.0|^6.0",

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
2222
use Symfony\UX\LiveComponent\ComponentValidator;
2323
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
24+
use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber;
2425
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
2526
use Symfony\UX\LiveComponent\LiveComponentHydrator;
2627
use Symfony\UX\LiveComponent\PropertyHydratorInterface;
@@ -47,6 +48,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
4748
'key' => $attribute->name,
4849
'template' => $attribute->template,
4950
'default_action' => $attribute->defaultAction,
51+
'live' => true,
5052
]))
5153
->addTag('controller.service_arguments')
5254
;
@@ -80,6 +82,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
8082

8183
$container->register('ux.live_component.twig.component_runtime', LiveComponentRuntime::class)
8284
->setArguments([
85+
new Reference('twig'),
8386
new Reference('ux.live_component.component_hydrator'),
8487
new Reference('ux.twig_component.component_factory'),
8588
new Reference(UrlGeneratorInterface::class),
@@ -92,6 +95,11 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
9295
->addTag('container.service_subscriber', ['key' => 'validator', 'id' => 'validator'])
9396
;
9497

98+
$container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class)
99+
->addTag('kernel.event_subscriber')
100+
->addTag('container.service_subscriber', ['key' => LiveComponentRuntime::class, 'id' => 'ux.live_component.twig.component_runtime'])
101+
;
102+
95103
$container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class);
96104
}
97105
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\EventListener;
4+
5+
use Psr\Container\ContainerInterface;
6+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
7+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
8+
use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
9+
use Symfony\UX\TwigComponent\ComponentAttributes;
10+
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
11+
12+
/**
13+
* @author Kevin Bond <[email protected]>
14+
*/
15+
final class AddLiveAttributesSubscriber implements EventSubscriberInterface, ServiceSubscriberInterface
16+
{
17+
public function __construct(private ContainerInterface $container)
18+
{
19+
}
20+
21+
public function onPreRender(PreRenderEvent $event): void
22+
{
23+
if (!$event->getMetadata()->get('live', false)) {
24+
// not a live component, skip
25+
return;
26+
}
27+
28+
/** @var ComponentAttributes $attributes */
29+
$attributes = $this->container->get(LiveComponentRuntime::class)
30+
->getLiveAttributes($event->getComponent(), $event->getMetadata())
31+
;
32+
33+
$variables = $event->getVariables();
34+
35+
if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) {
36+
// merge with existing attributes if available
37+
$attributes = $attributes->defaults($variables['attributes']->all());
38+
}
39+
40+
$variables['attributes'] = $attributes;
41+
42+
$event->setVariables($variables);
43+
}
44+
45+
public static function getSubscribedEvents(): array
46+
{
47+
return [PreRenderEvent::class => 'onPreRender'];
48+
}
49+
50+
public static function getSubscribedServices(): array
51+
{
52+
return [
53+
LiveComponentRuntime::class,
54+
];
55+
}
56+
}

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Symfony\UX\LiveComponent\Attribute\LiveArg;
3232
use Symfony\UX\LiveComponent\LiveComponentHydrator;
3333
use Symfony\UX\TwigComponent\ComponentFactory;
34+
use Symfony\UX\TwigComponent\ComponentMetadata;
3435
use Symfony\UX\TwigComponent\ComponentRenderer;
3536

3637
/**
@@ -73,24 +74,28 @@ public function onKernelRequest(RequestEvent $event): void
7374
$componentName = (string) $request->get('component');
7475

7576
try {
76-
$config = $this->container->get(ComponentFactory::class)->configFor($componentName);
77+
/** @var ComponentMetadata $metadata */
78+
$metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName);
7779
} catch (\InvalidArgumentException $e) {
7880
throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName), $e);
7981
}
8082

81-
$request->attributes->set('_component_template', $config['template']);
83+
if (!$metadata->get('live', false)) {
84+
throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName));
85+
}
86+
87+
$request->attributes->set('_component_metadata', $metadata);
8288

8389
if ('get' === $action) {
84-
$defaultAction = trim($config['default_action'] ?? '__invoke', '()');
85-
$componentClass = $config['class'];
90+
$defaultAction = trim($metadata->get('default_action', '__invoke'), '()');
8691

87-
if (!method_exists($componentClass, $defaultAction)) {
92+
if (!method_exists($metadata->getClass(), $defaultAction)) {
8893
// todo should this check be in a compiler pass to ensure fails at compile time?
89-
throw new \LogicException(sprintf('Live component "%s" requires the default action method "%s".%s', $componentClass, $defaultAction, '__invoke' === $defaultAction ? ' Either add this method or use the DefaultActionTrait' : ''));
94+
throw new \LogicException(sprintf('Live component "%s" (%s) requires the default action method "%s".%s', $metadata->getClass(), $componentName, $defaultAction, '__invoke' === $defaultAction ? ' Either add this method or use the DefaultActionTrait' : ''));
9095
}
9196

9297
// set default controller for "default" action
93-
$request->attributes->set('_controller', sprintf('%s::%s', $config['service_id'], $defaultAction));
98+
$request->attributes->set('_controller', sprintf('%s::%s', $metadata->getServiceId(), $defaultAction));
9499
$request->attributes->set('_component_default_action', true);
95100

96101
return;
@@ -106,7 +111,7 @@ public function onKernelRequest(RequestEvent $event): void
106111
throw new BadRequestHttpException('Invalid CSRF token.');
107112
}
108113

109-
$request->attributes->set('_controller', sprintf('%s::%s', $config['service_id'], $action));
114+
$request->attributes->set('_controller', sprintf('%s::%s', $metadata->getServiceId(), $action));
110115
}
111116

112117
public function onKernelController(ControllerEvent $event): void
@@ -232,7 +237,7 @@ private function createResponse(object $component, Request $request): Response
232237

233238
$html = $this->container->get(ComponentRenderer::class)->render(
234239
$component,
235-
$request->attributes->get('_component_template')
240+
$request->attributes->get('_component_metadata')
236241
);
237242

238243
return new Response($html);

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,15 @@ A real-time product search component might look like this::
4343
The ability to reference local variables in the template (e.g. ``query``) was added in TwigComponents 2.1.
4444
Previously, all data needed to be referenced through ``this`` (e.g. ``this.query``).
4545

46+
.. versionadded:: 2.1
47+
48+
The ability to initialize live component with the ``attributes`` twig variable was added in LiveComponents
49+
2.1. Previously, the ``init_live_component()`` function was required (this function was removed in 2.1).
50+
4651
.. code-block:: twig
4752
4853
{# templates/components/product_search.html.twig #}
49-
<div {{ init_live_component(this) }}>
54+
<div {{ attributes }}>
5055
<input
5156
type="search"
5257
name="query"
@@ -159,13 +164,13 @@ re-rendered live on the frontend), replace the component's
159164
}
160165
161166
Then, in the template, make sure there is *one* HTML element around your
162-
entire component and use the ``{{ init_live_component() }}`` function to
163-
initialize the Stimulus controller:
167+
entire component and use the ``{{ attributes }}`` variable to initialize
168+
the Stimulus controller:
164169

165170
.. code-block:: diff
166171
167172
- <div>
168-
+ <div {{ init_live_component(this) }}>
173+
+ <div {{ attributes }}>
169174
<strong>{{ this.randomNumber }}</strong>
170175
</div>
171176
@@ -176,7 +181,7 @@ and give the user a new random number:
176181

177182
.. code-block:: twig
178183
179-
<div {{ init_live_component(this) }}>
184+
<div {{ attributes }}>
180185
<strong>{{ this.randomNumber }}</strong>
181186
182187
<button
@@ -239,6 +244,44 @@ exceptions being properties that hold services (these don't need to be
239244
stateful because they will be autowired each time before the component
240245
is rendered) and `properties used for computed properties`_.
241246

247+
Component Attributes
248+
--------------------
249+
250+
.. versionadded:: 2.1
251+
252+
The ``HasAttributes`` trait was added in TwigComponents 2.1.
253+
254+
`Component attributes`_ allows you to render your components with extra
255+
props that are are converted to html attributes and made available in
256+
your component's template as an ``attributes`` variable. When used on
257+
live components, these props are persisted between renders. You can enable
258+
this feature by having your live component use the ``HasAttributesTrait``:
259+
260+
.. code-block:: diff
261+
262+
// ...
263+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
264+
+ use Symfony\UX\TwigComponent\HasAttributesTrait;
265+
266+
#[AsLiveComponent('random_number')]
267+
class RandomNumberComponent
268+
{
269+
+ use HasAttributesTrait;
270+
271+
#[LiveProp]
272+
public int $min = 0;
273+
274+
Now, when rendering your component, you can pass html attributes
275+
as props and these will be added to ``attributes``:
276+
277+
.. code-block:: twig
278+
279+
{{ component('random_number', { min: 5, max: 500, class: 'widget', style: 'color: black;' }) }}
280+
281+
{# renders as: #}
282+
<div class="widget" style="color: black;" <!-- other live attributes -->>
283+
<!-- ... -->
284+
242285
data-action=“live#update”: Re-rendering on LiveProp Change
243286
----------------------------------------------------------
244287

@@ -251,7 +294,7 @@ Let's add two inputs to our template:
251294
.. code-block:: twig
252295
253296
{# templates/components/random_number.html.twig #}
254-
<div {{ init_live_component(this) }}>
297+
<div {{ attributes }}>
255298
<input
256299
type="number"
257300
value="{{ min }}"
@@ -368,7 +411,7 @@ property. The following code works identically to the previous example:
368411

369412
.. code-block:: diff
370413
371-
<div {{ init_live_component(this)>
414+
<div {{ attributes }}>
372415
<input
373416
type="number"
374417
value="{{ min }}"
@@ -791,7 +834,7 @@ as ``this.form`` thanks to the trait:
791834
792835
{# templates/components/post_form.html.twig #}
793836
<div
794-
{{ init_live_component(this) }}
837+
{{ attributes }}
795838
{#
796839
Automatically catch all "change" events from the fields
797840
below and re-render the component.
@@ -815,8 +858,7 @@ as ``this.form`` thanks to the trait:
815858
</div>
816859
817860
Mostly, this is a pretty boring template! It includes the normal
818-
``init_live_component(this)`` and then you render the form however you
819-
want.
861+
``attributes`` and then you render the form however you want.
820862

821863
But the result is incredible! As you finish changing each field, the
822864
component automatically re-renders - including showing any validation
@@ -1024,7 +1066,7 @@ section above) is to add:
10241066
.. code-block:: diff
10251067
10261068
<div
1027-
{{ init_live_component(this) }}
1069+
{{ attributes }}
10281070
+ data-action="change->live#update"
10291071
>
10301072
@@ -1056,7 +1098,7 @@ rendered the ``content`` through a Markdown filter from the
10561098

10571099
.. code-block:: twig
10581100
1059-
<div {{init_live_component(this)}}>
1101+
<div {{ attributes }}>
10601102
<input
10611103
type="text"
10621104
value="{{ post.title }}"
@@ -1221,7 +1263,7 @@ You can also use “polling” to continually refresh a component. On the
12211263
.. code-block:: diff
12221264
12231265
<div
1224-
{{ init_live_component(this) }}
1266+
{{ attributes }}
12251267
+ data-poll
12261268
>
12271269
@@ -1233,7 +1275,7 @@ delay for 500ms:
12331275
.. code-block:: twig
12341276
12351277
<div
1236-
{{ init_live_component(this) }}
1278+
{{ attributes }}
12371279
data-poll="delay(500)|$render"
12381280
>
12391281
@@ -1242,7 +1284,7 @@ You can also trigger a specific “action” instead of a normal re-render:
12421284
.. code-block:: twig
12431285
12441286
<div
1245-
{{ init_live_component(this) }}
1287+
{{ attributes }}
12461288
12471289
data-poll="save"
12481290
{#
@@ -1437,7 +1479,7 @@ In the ``EditPostComponent`` template, you render the
14371479
.. code-block:: twig
14381480
14391481
{# templates/components/edit_post.html.twig #}
1440-
<div {{ init_live_component(this) }}>
1482+
<div {{ attributes }}>
14411483
<input
14421484
type="text"
14431485
name="post[title]"
@@ -1459,7 +1501,7 @@ In the ``EditPostComponent`` template, you render the
14591501
14601502
.. code-block:: twig
14611503
1462-
<div {{ init_live_component(this) }} class="mb-3">
1504+
<div {{ attributes }} class="mb-3">
14631505
<textarea
14641506
name="{{ name }}"
14651507
data-model="value"
@@ -1496,3 +1538,4 @@ bound to Symfony's BC policy for the moment.
14961538
.. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
14971539
.. _`dependent form fields`: https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms
14981540
.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html
1541+
.. _`Component attributes`: https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes

src/LiveComponent/src/Twig/LiveComponentExtension.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ final class LiveComponentExtension extends AbstractExtension
2424
public function getFunctions(): array
2525
{
2626
return [
27-
new TwigFunction('init_live_component', [LiveComponentRuntime::class, 'renderLiveAttributes'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
2827
new TwigFunction('component_url', [LiveComponentRuntime::class, 'getComponentUrl']),
2928
];
3029
}

0 commit comments

Comments
 (0)