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
15 changes: 14 additions & 1 deletion src/LiveComponent/src/EventListener/DataModelPropsSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,21 @@ public function onPreMount(PreMountEvent $event): void
foreach ($bindings as $binding) {
$childModel = $binding['child'];
$parentModel = $binding['parent'];
$propValue = $this->propertyAccessor->getValue($parentMountedComponent->getComponent(), $parentModel);

$data[$childModel] = $this->propertyAccessor->getValue($parentMountedComponent->getComponent(), $parentModel);
if ('value' === $childModel && \array_key_exists('value', $data)) {
// Radio or checkbox group: an explicit option value was passed (e.g. value="a").
// Preserve it and derive the checked state from the parent prop instead.
$data['checked'] = \is_array($propValue)
? \in_array($data['value'], $propValue, false)
: ($data['value'] == $propValue);
} elseif ('value' === $childModel && isset($data['type']) && 'checkbox' === $data['type']) {
// Boolean checkbox: type="checkbox" declared at call site but no explicit value.
// Set checked directly from the boolean prop; do not write a value attribute.
$data['checked'] = (bool) $propValue;
} else {
$data[$childModel] = $propValue;
}
}

$event->setData($data);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent('checkbox_input')]
final class CheckboxInputComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('parent_with_bool_checkbox')]
final class ParentWithBoolCheckboxComponent
{
use DefaultActionTrait;

#[LiveProp(writable: true)]
public bool $isActive = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('parent_with_checkbox_group')]
final class ParentWithCheckboxGroupComponent
{
use DefaultActionTrait;

#[LiveProp(writable: true)]
public array $selected = ['a', 'c'];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('parent_with_radio_group')]
final class ParentWithRadioGroupComponent
{
use DefaultActionTrait;

#[LiveProp(writable: true)]
public string $selected = 'b';
}
10 changes: 10 additions & 0 deletions src/LiveComponent/tests/Fixtures/Component/RadioInputComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent('radio_input')]
final class RadioInputComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<input{{ attributes }} />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% component checkbox_input with { 'data-model': 'isActive', type: 'checkbox' } %}{% endcomponent %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% component checkbox_input with { 'data-model': 'selected', value: 'a', type: 'checkbox' } %}{% endcomponent %}
{% component checkbox_input with { 'data-model': 'selected', value: 'b', type: 'checkbox' } %}{% endcomponent %}
{% component checkbox_input with { 'data-model': 'selected', value: 'c', type: 'checkbox' } %}{% endcomponent %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% component radio_input with { 'data-model': 'selected', value: 'a', type: 'radio' } %}{% endcomponent %}
{% component radio_input with { 'data-model': 'selected', value: 'b', type: 'radio' } %}{% endcomponent %}
{% component radio_input with { 'data-model': 'selected', value: 'c', type: 'radio' } %}{% endcomponent %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<input{{ attributes }} />
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,66 @@ public function testDataModelPropsAreAvailableInEmbeddedComponents()
$this->assertStringContainsString('<textarea data-model="content">default content on mount</textarea>', $html);
$this->assertStringContainsString('<input data-model="content" value="default content on mount" />', $html);
}

public function testRadioGroupPreservesValueAndSetsChecked()
{
/** @var ComponentRenderer $renderer */
$renderer = self::getContainer()->get('ux.twig_component.component_renderer');

$html = $renderer->createAndRender('parent_with_radio_group', [
'selected' => 'b',
'attributes' => ['id' => 'dummy-live-id'],
]);

// value attributes must be preserved as-is
$this->assertStringContainsString('value="a"', $html);
$this->assertStringContainsString('value="b"', $html);
$this->assertStringContainsString('value="c"', $html);

// only the matching radio gets checked
$this->assertStringContainsString('value="b" type="radio" checked', $html);

// non-matching radios must not get checked
$this->assertStringNotContainsString('value="a" type="radio" checked', $html);
$this->assertStringNotContainsString('value="c" type="radio" checked', $html);
}

public function testCheckboxGroupPreservesValueAndSetsChecked()
{
/** @var ComponentRenderer $renderer */
$renderer = self::getContainer()->get('ux.twig_component.component_renderer');

$html = $renderer->createAndRender('parent_with_checkbox_group', [
'selected' => ['a', 'c'],
'attributes' => ['id' => 'dummy-live-id'],
]);

// value attributes must be preserved as-is
$this->assertStringContainsString('value="a"', $html);
$this->assertStringContainsString('value="b"', $html);
$this->assertStringContainsString('value="c"', $html);

// selected checkboxes ('a' and 'c') get checked; unselected ('b') does not
$this->assertStringContainsString('value="a" type="checkbox" checked', $html);
$this->assertStringNotContainsString('value="b" type="checkbox" checked', $html);
$this->assertStringContainsString('value="c" type="checkbox" checked', $html);
}

public function testBooleanCheckboxSetsCheckedWithoutValueOverwrite()
{
/** @var ComponentRenderer $renderer */
$renderer = self::getContainer()->get('ux.twig_component.component_renderer');

$html = $renderer->createAndRender('parent_with_bool_checkbox', [
'isActive' => true,
'attributes' => ['id' => 'dummy-live-id'],
]);

// checked attribute must be present (positive assertion — the fix works)
$this->assertStringContainsString(' checked', $html);

// pre-fix symptoms: subscriber must NOT have written value="1" (true cast) or value="" (false cast)
$this->assertStringNotContainsString('value="1"', $html);
$this->assertStringNotContainsString('value=""', $html);
}
}
Loading