diff --git a/ux.symfony.com/src/Controller/Demo/LiveDemoController.php b/ux.symfony.com/src/Controller/Demo/LiveDemoController.php index afbfb9cb0e6..c02b5915780 100644 --- a/ux.symfony.com/src/Controller/Demo/LiveDemoController.php +++ b/ux.symfony.com/src/Controller/Demo/LiveDemoController.php @@ -93,6 +93,7 @@ public function invoice(LiveDemoRepository $liveDemoRepository, ?Invoice $invoic } #[Route('/{demo}', name: 'app_demo_live_component_demo')] + #[Route('/animalz', name: 'app_demo_live_component_animalz')] #[Route('/auto-validating-form', name: 'app_demo_live_component_auto_validating_form')] #[Route('/chartjs', name: 'app_demo_live_component_chartjs')] #[Route('/dependent-form-fields', name: 'app_demo_live_component_dependent_form_fields')] diff --git a/ux.symfony.com/src/Enum/AnimalzType.php b/ux.symfony.com/src/Enum/AnimalzType.php new file mode 100644 index 00000000000..6074152f4c9 --- /dev/null +++ b/ux.symfony.com/src/Enum/AnimalzType.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Enum; + +enum AnimalzType: string +{ + case Normal = 'Normal'; + case Water = 'Water'; + case Flying = 'Flying'; + case Bug = 'Bug'; + case Fight = 'Fight'; + case Dark = 'Dark'; + case Poison = 'Poison'; + case Grass = 'Grass'; + case Fairy = 'Fairy'; + case Fossil = 'Fossil'; + + public function getColor(): string + { + return match ($this) { + self::Normal => '#9fa19f', + self::Water => '#2980ef', + self::Flying => '#81b9ef', + self::Bug => '#91a119', + self::Fight => '#ff8100', + self::Dark => '#4f3f3d', + self::Poison => '#9141cb', + self::Grass => '#3fa129', + self::Fairy => '#ef70ef', + self::Fossil => '#5060e1', + }; + } +} diff --git a/ux.symfony.com/src/Model/Animalz.php b/ux.symfony.com/src/Model/Animalz.php new file mode 100644 index 00000000000..6095039b323 --- /dev/null +++ b/ux.symfony.com/src/Model/Animalz.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Model; + +use App\Enum\AnimalzType; + +final class Animalz +{ + public function __construct( + public string $name, + public AnimalzType $type1, + public ?AnimalzType $type2, + public int $legs, + public string $description, + ) { + } +} diff --git a/ux.symfony.com/src/Service/AnimalzRepository.php b/ux.symfony.com/src/Service/AnimalzRepository.php new file mode 100644 index 00000000000..ca69287aac2 --- /dev/null +++ b/ux.symfony.com/src/Service/AnimalzRepository.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service; + +use App\Enum\AnimalzType; +use App\Model\Animalz; + +class AnimalzRepository +{ + private const string DATA_FILE = __DIR__.'/data/animalz-properties.json'; + + /** @var list|null */ + private ?array $zanimalz = null; + + /** + * @return list + */ + public function findAll(): array + { + return $this->zanimalz ??= $this->loadZanimalz(); + } + + /** + * @return list + */ + public function findByNameAndTypeAndLegs(?string $name, ?AnimalzType $type, ?int $maxLegs): array + { + $zanimalz = $this->findAll(); + + if (null !== $name && '' !== $name) { + $zanimalz = array_filter( + $zanimalz, + fn (Animalz $animalz): bool => str_contains( + strtolower($animalz->name), + strtolower($name), + ), + ); + } + + if (null !== $type) { + $zanimalz = array_filter( + $zanimalz, + fn (Animalz $animalz): bool => $animalz->type1 === $type || $animalz->type2 === $type, + ); + } + + if (null !== $maxLegs) { + $zanimalz = array_filter( + $zanimalz, + fn (Animalz $animalz): bool => $animalz->legs <= $maxLegs, + ); + } + + $zanimalz = array_values($zanimalz); + usort($zanimalz, fn (Animalz $a, Animalz $b): int => $a->name <=> $b->name); + + return $zanimalz; + } + + public function getMaxLegs(): int + { + $zanimalz = $this->findAll(); + + return max(array_map(fn (Animalz $a): int => $a->legs, $zanimalz)); + } + + /** + * @return list + */ + private function loadZanimalz(): array + { + $content = file_get_contents(self::DATA_FILE); + + if (false === $content) { + return []; + } + + $data = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + + return array_map( + fn (array $item): Animalz => new Animalz( + name: $item['name'], + type1: AnimalzType::from($item['type'][0]), + type2: isset($item['type'][1]) ? AnimalzType::from($item['type'][1]) : null, + legs: $item['legs'], + description: $item['description'], + ), + $data, + ); + } +} diff --git a/ux.symfony.com/src/Service/LiveDemoRepository.php b/ux.symfony.com/src/Service/LiveDemoRepository.php index c65a6a16f14..5ddf0d491e8 100644 --- a/ux.symfony.com/src/Service/LiveDemoRepository.php +++ b/ux.symfony.com/src/Service/LiveDemoRepository.php @@ -21,6 +21,19 @@ class LiveDemoRepository public function findAll(): array { return [ + new LiveDemo( + identifier: 'animalz', + name: 'Facet Filtering', + description: 'Filter results based on a facet menu, with URL parameters.', + author: 'Nayte', + publishedAt: '2025-12-12', + tags: ['facets', 'filter', 'LiveAction'], + longDescription: <<<'EOF' + This demo showcases faceted search with two Live Components working together. + + Filter a list of animals by their **number of legs**, **weight**, or **natural habitat** using a dynamic facet menu that updates the results in real time. + EOF, + ), new LiveDemo( 'infinite-scroll-2', name: 'Infinite Scroll - 2/2', @@ -29,7 +42,7 @@ public function findAll(): array publishedAt: '2024-06-07', tags: ['grid', 'pagination', 'loading', 'scroll'], longDescription: << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\Components\Animalz; + +use App\Enum\AnimalzType; +use App\Service\AnimalzRepository; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\ComponentToolsTrait; +use Symfony\UX\LiveComponent\DefaultActionTrait; + +#[AsLiveComponent(name: 'Animalz:FacetMenu', template: 'components/Animalz/FacetMenu.html.twig')] +final class FacetMenu +{ + use ComponentToolsTrait; + use DefaultActionTrait; + + private const array EMITTABLE_PROPS = ['name', 'type', 'maxLegs']; + + #[LiveProp(writable: true, onUpdated: 'emitChange', url: true)] + public ?string $name = null; + + #[LiveProp(writable: true, onUpdated: 'emitChange', url: true)] + public ?AnimalzType $type = null; + + #[LiveProp(writable: true, onUpdated: 'emitChange', url: true)] + public ?int $maxLegs = null; + + public function __construct( + private readonly AnimalzRepository $animalzRepository, + ) { + } + + #[LiveAction] + public function reset(): void + { + $this->name = null; + $this->type = null; + $this->maxLegs = null; + + $this->emitChange(); + } + + public function emitChange(): void + { + $this->nullTheProps(); + $this->emit('facetSetted', $this->convertPropertiesToAssociativeArray()); + } + + /** @return list */ + public function getTypeChoices(): array + { + return AnimalzType::cases(); + } + + public function getLegsMax(): int + { + return $this->animalzRepository->getMaxLegs(); + } + + /** @return array */ + private function convertPropertiesToAssociativeArray(): array + { + $props = array_intersect_key( + get_object_vars($this), + array_flip(self::EMITTABLE_PROPS), + ); + + if ($props['type'] instanceof AnimalzType) { + $props['type'] = $props['type']->value; + } + + return $props; + } + + private function nullTheProps(): void + { + $this->name = '' === $this->name ? null : $this->name; + } +} diff --git a/ux.symfony.com/src/Twig/Components/Animalz/Results.php b/ux.symfony.com/src/Twig/Components/Animalz/Results.php new file mode 100644 index 00000000000..16c4a33f632 --- /dev/null +++ b/ux.symfony.com/src/Twig/Components/Animalz/Results.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\Components\Animalz; + +use App\Enum\AnimalzType; +use App\Model\Animalz; +use App\Service\AnimalzRepository; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\Attribute\LiveListener; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; + +#[AsLiveComponent(name: 'Animalz:Results', template: 'components/Animalz/Results.html.twig')] +final class Results +{ + use DefaultActionTrait; + + #[LiveProp(url: true)] + public ?string $name = null; + + #[LiveProp(url: true)] + public ?AnimalzType $type = null; + + #[LiveProp(url: true)] + public ?int $maxLegs = null; + + public function __construct( + private readonly AnimalzRepository $animalzRepository, + ) { + } + + #[LiveListener('facetSetted')] + public function reload( + #[LiveArg] + ?string $name, + #[LiveArg] + ?string $type, + #[LiveArg] + ?int $maxLegs, + ): void { + $this->name = $name; + $this->type = null !== $type ? AnimalzType::tryFrom($type) : null; + $this->maxLegs = $maxLegs; + } + + /** @return Animalz[] */ + public function getResults(): array + { + return $this->animalzRepository->findByNameAndTypeAndLegs($this->name, $this->type, $this->maxLegs); + } +} diff --git a/ux.symfony.com/templates/components/Animalz/FacetMenu.html.twig b/ux.symfony.com/templates/components/Animalz/FacetMenu.html.twig new file mode 100644 index 00000000000..e54e25c4d69 --- /dev/null +++ b/ux.symfony.com/templates/components/Animalz/FacetMenu.html.twig @@ -0,0 +1,43 @@ +
+
+
+ Filters + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
diff --git a/ux.symfony.com/templates/components/Animalz/Results.html.twig b/ux.symfony.com/templates/components/Animalz/Results.html.twig new file mode 100644 index 00000000000..0a97c052e5a --- /dev/null +++ b/ux.symfony.com/templates/components/Animalz/Results.html.twig @@ -0,0 +1,22 @@ +{% set results = this.results %} +
+
+ Results + {{ results|length }} +
+
+
+ {% for result in results %} + {% set color1 = result.type1.color %} + {% set color2 = result.type2 ? result.type2.color : null %} + {% set background = color2 is null + ? color1 + : 'linear-gradient(110deg, ' ~ color1 ~ ' 0%, ' ~ color1 ~ ' 40%, ' ~ color2 ~ ' 60%, ' ~ color2 ~ ' 100%)' + %} +
{{ result.name|capitalize }}
+ {% else %} +

No animalz found.

+ {% endfor %} +
+
+
diff --git a/ux.symfony.com/templates/demos/live_component/animalz.html.twig b/ux.symfony.com/templates/demos/live_component/animalz.html.twig new file mode 100644 index 00000000000..ab1150c7b30 --- /dev/null +++ b/ux.symfony.com/templates/demos/live_component/animalz.html.twig @@ -0,0 +1,20 @@ +{% extends 'demos/live_demo.html.twig' %} + +{% block demo_content %} +
+
+ +
+
+ +
+
+{% endblock %} + +{% block code_block_left %} + +{% endblock %} + +{% block code_block_right %} + +{% endblock %}