Componentes en vivo
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login Subscribe¡Feliz día 27 de LAST Stack! Hemos conseguido mucho durante los primeros 26 días con sólo tres letras de LAST Stack: Mapeador de Activos, Stimulus y Turbo. Hoy desciframos el código de la L de LAST Stack: Componentes en vivo. Los componentes en vivo nos permiten tomar un componente Twig... y volver a renderizarlo mediante Ajax cuando el usuario interactúa con él.
Nuestro objetivo es esta búsqueda global. Cuando hago clic, ¡no pasa nada! Lo que quiero hacer es abrir un modal con un cuadro de búsqueda que, a medida que escribimos, cargue una búsqueda en vivo.
Abrir el modal de búsqueda
Comienza dentro de templates/base.html.twig
. ¡Busca la búsqueda! Perfecto: esta es la entrada de búsqueda falsa que acabamos de ver. Añade un <twig:Modal>
con :closeButton="true"
, luego un <twig:block>
con name="trigger"
. Para que esto abra el modal, necesitamos data-action="modal#open"
:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true"> | |
<twig:block name="trigger"> | |
<div | |
class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer" | |
data-action="modal#open" | |
> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
<span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span> | |
</div> | |
</twig:block> | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 45 - 54 | |
</div> | |
// ... lines 56 - 84 | |
</body> | |
</html> |
¡Genial! Si actualizamos, no cambia nada: la única parte visible del modal es el activador. Para el contenido del modal, después del bloque Twig, pegaré un div:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true"> | |
<twig:block name="trigger"> | |
<div | |
class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer" | |
data-action="modal#open" | |
> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
<span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span> | |
</div> | |
</twig:block> | |
<div class="relative"> | |
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
</div> | |
<input | |
type="search" | |
aria-label="Search site" | |
placeholder="Search for anything" | |
class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none" | |
/> | |
</div> | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 57 - 66 | |
</div> | |
// ... lines 68 - 96 | |
</body> | |
</html> |
Aquí no hay nada especial: sólo una entrada de búsqueda real.
De vuelta al navegador, cuando hago clic... uh oh. ¡no pasa nada! La depuración se inicia siempre en la consola. No hay errores, pero cuando hago clic, mira: no hay ningún registro que diga que se está lanzando la acción. ¿Hay algo que no funciona y tal vez hayas visto mi error? Añadimos el data-action
a un div
. A diferencia de un button
o un form
, Stimulus no tiene un evento predeterminado para un div
. Añade click->
:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true"> | |
<twig:block name="trigger"> | |
<div | |
// ... line 35 | |
data-action="click->modal#open" | |
> | |
// ... lines 38 - 39 | |
</div> | |
</twig:block> | |
// ... lines 42 - 53 | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 57 - 66 | |
</div> | |
// ... lines 68 - 96 | |
</body> | |
</html> |
Y ahora... ¡ya está!
Ah, ¡y autoenfoca la entrada! Eso es.... ¡una característica de los diálogos! Funcionan como una minipágina dentro de una página: autoenfoca el primer elemento tabulable... o puedes utilizar el atributo normal autofocus
para tener más control. Funciona como tú quieras.
Modal: Controla el relleno
De todos modos, soy quisquilloso: esto tiene más relleno del que quiero. Pero ¡no pasa nada! Podemos hacer que nuestro componente Modal sea un poco más flexible. En components/Modal.html.twig
, el relleno extra es este p-5
. En la parte superior, añade un tercer prop
: padding='p-5'
. Cópialo. Y aquí abajo, renderiza padding
:
{% props allowSmallWidth=false, closeButton=false, padding="p-5" %} | |
<div | |
// ... lines 3 - 6 | |
> | |
// ... lines 8 - 9 | |
<dialog | |
// ... lines 11 - 13 | |
> | |
<div class="flex grow {{ padding }}"> | |
// ... lines 16 - 18 | |
</div> | |
// ... lines 20 - 28 | |
</dialog> | |
// ... lines 30 - 35 | |
</div> |
En base.html.twig
, en el modal, añade padding
igual a comillas vacías:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true" padding=""> | |
// ... lines 33 - 53 | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 57 - 66 | |
</div> | |
// ... lines 68 - 96 | |
</body> | |
</html> |
¡Vamos a comprobarlo! Y... mucho más ordenado.
Crear el componente Twig
Para dar vida a los resultados, podríamos repetir la configuración de las tablas de datos de la página de inicio. Podríamos añadir un <turbo-frame>
con los resultados justo aquí y hacer que la entrada se autoenvíe a ese marco.
Otra opción es construir esto con un componente en vivo. Pero antes de hablar de eso, organicemos primero el contenido del modal en un componente Twig.
En templates/components/
, crea un nuevo archivo llamado SearchSite.html.twig
. Añadiré un div con {{ attributes }}
. Luego ve a robar todo el cuerpo del modal, y pégalo aquí:
<div {{ attributes }}> | |
<div class="relative"> | |
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
</div> | |
<input | |
type="search" | |
aria-label="Search site" | |
placeholder="Search for anything" | |
class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none" | |
/> | |
</div> | |
</div> |
En base.html.twig
, es fácil, ¿verdad? <twig:SearchSite />
y listo:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true" padding=""> | |
<twig:block name="trigger"> | |
// ... lines 34 - 40 | |
</twig:block> | |
<twig:SearchSite /> | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 47 - 56 | |
</div> | |
// ... lines 58 - 86 | |
</body> | |
</html> |
En el navegador, obtenemos el mismo resultado.
Obtención de datos con un componente Twig
La búsqueda del sitio va a ser realmente una búsqueda de viaje. Para obtener los resultados, tenemos dos opciones. En primer lugar, podríamos... obtener de algún modo los viajes que queremos mostrar dentro de base.html.twig
y pasarlos a SearchSite
como prop. Pero... obtener datos de nuestro diseño base es complicado... probablemente necesitaríamos una función Twig personalizada.
La segunda opción es aprovechar nuestro componente Twig Uno de sus superpoderes es la capacidad de obtener sus propios datos: ser autónomo.
Para ello, este componente Twig necesita ahora una clase PHP. En src/Twig/Components/
, crea una nueva clase PHP llamada SearchSite
. Lo único que necesita para ser reconocido como componente Twig es un atributo: #[AsTwigComponent]
:
// ... lines 1 - 2 | |
namespace App\Twig\Components; | |
// ... lines 4 - 6 | |
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; | |
class SearchSite | |
{ | |
// ... lines 12 - 22 | |
} |
Esto es exactamente lo que vimos dentro de la clase Button
. Hace unos días, mencioné rápidamente que las clases de componentes Twig son servicios, lo que significa que podemos autoconectar otros servicios como VoyageRepository
, $voyageRepository
:
// ... lines 1 - 5 | |
use App\Repository\VoyageRepository; | |
// ... lines 7 - 8 | |
class SearchSite | |
{ | |
public function __construct(private VoyageRepository $voyageRepository) | |
{ | |
} | |
// ... lines 15 - 22 | |
} |
Para proporcionar los datos a la plantilla, crea un nuevo método llamado voyages()
. Éste devolverá una matriz... que en realidad será una matriz de Voyage[]
. Dentro dereturn $this->voyageRepository->findBySearch()
. Es el mismo método que utilizamos en la página de inicio. Pasa null
, un array vacío, y limita a 10 resultados:
// ... lines 1 - 4 | |
use App\Entity\Voyage; | |
// ... lines 6 - 8 | |
class SearchSite | |
{ | |
// ... lines 12 - 15 | |
/** | |
* @return Voyage[] | |
*/ | |
public function voyages(): array | |
{ | |
return $this->voyageRepository->findBySearch(null, [], 10); | |
} | |
} |
La consulta de búsqueda aún no es dinámica, pero ahora tenemos un método voyages()
que podemos utilizar en la plantilla. Empezaré con un poco de estilo, luego es código Twig normal: {% for voyage in this
- ese es nuestro objeto componente -.voyages
. Añade endfor
, y en el medio, pegaré eso:
<div {{ attributes }}> | |
// ... lines 2 - 13 | |
<div class="text-white py-2 rounded-lg"> | |
{% for voyage in this.voyages %} | |
<a href="{{ path('app_voyage_show', { id: voyage.id }) }}" class="flex items-center space-x-4 px-4 p-2 hover:bg-gray-700 cursor-pointer"> | |
<img | |
class="h-10 w-10 rounded-full" | |
src="{{ asset('images/'~voyage.planet.imageFilename) }}" | |
alt="{{ voyage.planet.name }} planet" | |
> | |
<div> | |
<p class="text-sm font-medium text-white">{{ voyage.purpose }}</p> | |
<p class="text-xs text-gray-400">{{ voyage.leaveAt|ago }}</p> | |
</div> | |
</a> | |
{% endfor %} | |
</div> | |
</div> |
Nada especial: una etiqueta de anclaje, una etiqueta de imagen y algo de información.
Vamos a probarlo. ¡Abre! ¡Genial! Aunque, por supuesto, cuando escribimos, ¡nada se actualiza! Lamentable!
Instalar y actualizar un LiveComponent
Aquí es donde los componentes en vivo resultan útiles. ¡Así que vamos a instalarlo!
composer require symfony/ux-live-component
Para actualizar nuestro componente Twig a un componente Live, sólo tenemos que hacer dos cosas. Primero, es #[AsLiveComponent]
. Y segundo, utilizar DefaultActionTrait
:
// ... lines 1 - 6 | |
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; | |
use Symfony\UX\LiveComponent\DefaultActionTrait; | |
class SearchSite | |
{ | |
use DefaultActionTrait; | |
// ... lines 14 - 25 | |
} |
Es un detalle interno... pero necesario.
Hasta aquí, nada cambiará. Sigue siendo un componente Twig... y no hemos añadido ninguna superpotencia de componente en vivo.
Añadir un accesorio escribible
Uno de los conceptos clave con un componente vivo es que puedes añadir una propiedad y permitir que el usuario cambie esa propiedad desde el frontend. Por ejemplo, crea un public string $query
para representar la cadena de búsqueda:
// ... lines 1 - 10 | |
class SearchSite | |
{ | |
// ... lines 14 - 16 | |
public string $query = ''; | |
// ... lines 18 - 29 | |
} |
A continuación, utilízala cuando llamemos al repositorio:
// ... lines 1 - 10 | |
class SearchSite | |
{ | |
// ... lines 14 - 16 | |
public string $query = ''; | |
// ... lines 18 - 25 | |
public function voyages(): array | |
{ | |
return $this->voyageRepository->findBySearch($this->query, [], 10); | |
} | |
} |
Para permitir que el usuario modifique esta propiedad, necesitamos darle un atributo:#[LiveProp]
con writeable: true
:
// ... lines 1 - 7 | |
use Symfony\UX\LiveComponent\Attribute\LiveProp; | |
// ... lines 9 - 10 | |
class SearchSite | |
{ | |
// ... lines 14 - 15 | |
writable: true) | (|
public string $query = ''; | |
// ... lines 18 - 29 | |
} |
Por último, para vincular esta propiedad a la entrada -de modo que la propiedad query
cambie a medida que el usuario escribe- añade data-model="query"
:
<div {{ attributes }}> | |
<div class="relative"> | |
// ... lines 3 - 5 | |
<input | |
type="search" | |
data-model="query" | |
// ... lines 9 - 11 | |
/> | |
</div> | |
// ... lines 14 - 30 | |
</div> |
¡Ya está! Comprueba el resultado. Empezamos con todo, pero cuando tecleamos... ¡filtra! Incluso tiene depuración incorporada.
Entre bastidores, hace una petición AJAX, rellena la propiedad query
con esta cadena, vuelve a renderizar la plantilla Twig y la coloca justo aquí.
Y, mira, es sólo PHP, así que esto es fácil. Si no es $this->query
, devuelve un array vacío:
// ... lines 1 - 10 | |
class SearchSite | |
{ | |
// ... lines 14 - 25 | |
public function voyages(): array | |
{ | |
if (!$this->query) { | |
return []; | |
} | |
// ... lines 31 - 32 | |
} | |
} |
Y en SearchSite.html.twig
, añade una declaración if alrededor de esto: if this.voyages
no está vacío, devuelve eso... con el endif
al final:
<div {{ attributes }}> | |
// ... lines 2 - 14 | |
{% if this.voyages is not empty %} | |
<div class="text-white py-2 rounded-lg"> | |
{% for voyage in this.voyages %} | |
// ... lines 18 - 29 | |
{% endfor %} | |
</div> | |
{% endif %} | |
</div> |
Para los quisquillosos con los detalles, sí, con this.voyages
, estamos llamando al método dos veces. Pero hay formas de evitarlo, y mi favorita se llama #[ExposeInTemplate]
. No la mostraré, pero es un cambio rápido.
Fijar el modal a la parte superior
Así que, ¡estoy contento! Pero, esto no es perfecto... y yo quiero eso. Una cosa que me molesta es la posición: parece baja cuando está vacía. Y al escribir, salta de un lado a otro. Es el posicionamiento nativo de <dialog>
, que normalmente es estupendo, pero no cuando nuestro contenido cambia. Así que, en este caso, vamos a fijar la posición cerca de la parte superior.
En Modal.html.twig
, añade una última pieza de flexibilidad a nuestro componente: una prop llamada fixedTop = false
:
{% props | |
// ... lines 2 - 3 | |
padding="p-5", | |
fixedTop=false | |
%} | |
// ... lines 7 - 42 |
Entonces, al final de las clases dialog
, si fixedTop
, renderiza mt-14
para fijar el margen superior. Si no, no hagas nada:
// ... lines 1 - 6 | |
<div | |
// ... lines 8 - 11 | |
> | |
// ... lines 13 - 14 | |
<dialog | |
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] {{ allowSmallWidth ? '' : 'md:min-w-[50%] ' }}animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80{{ fixedTop ? ' mt-14' : '' }}" | |
// ... lines 17 - 18 | |
> | |
// ... lines 20 - 33 | |
</dialog> | |
// ... lines 35 - 40 | |
</div> |
En base.html.twig
, en el modal... es hora de dividirlo en varias líneas. Entonces pasa a :fixedTop="true"
:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true" padding="" :fixedTop="true"> | |
// ... lines 33 - 43 | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 47 - 56 | |
</div> | |
// ... lines 58 - 86 | |
</body> | |
</html> |
Y ahora, ah. Mucho más bonito y sin más saltos.
Establecer la Búsqueda como Turbo Permanente
¿Y qué más? Es necesario pulsar arriba y abajo en el teclado para recorrer los resultados, aunque eso lo dejaré para otra ocasión. Pero fíjate en esto. Si busco, luego hago clic fuera y navego a otra página, como es lógico, al abrir el modal de búsqueda, está vacío. Sería genial que recordara la búsqueda.
Y podemos hacerlo con un truco de Turbo. En base.html.twig
, en el modal, añade data-turbo-permanent
:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal :closeButton="true" padding="" :fixedTop="true" data-turbo-permanent id="global-search-modal"> | |
// ... lines 33 - 43 | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 47 - 56 | |
</div> | |
// ... lines 58 - 86 | |
</body> | |
</html> |
Eso le dice a Turbo que mantenga esto en la página cuando navegue. Cuando utilices esto, necesita un id.
Veamos cómo se siente esto. Abre la búsqueda, escribe algo, pulsa fuera, ve a la página principal y ábrela de nuevo. ¡Qué guay!
Abrir la búsqueda con Ctrl+K
Vale, ¡última cosa! Aquí arriba estoy anunciando que abres la búsqueda con un atajo de teclado. ¡Eso es mentira! Pero podemos añadir esto... y, de nuevo, es fácil.
En el modal, añade un data-action
. Stimulus tiene soporte incorporado para hacer cosas en keydown
. Así que podemos decir keydown.
, y luego la tecla que queramos, comoK
. O en este caso, Ctrl
+K
.
Si nos detuviéramos ahora, esto sólo se activaría si el modal estuviera enfocado y luego alguien pulsara Ctrl
+K
. Eso... no va a ocurrir. En lugar de eso, queremos que se abra independientemente de lo que esté enfocado. Queremos una escucha global. Para ello, añade@window
.
Cópialo, añade un espacio, pégalo y activa también meta+k
. Meta es la tecla comando en un Mac:
<html> | |
// ... lines 3 - 15 | |
<body class="bg-black text-white font-mono"> | |
<div class="container mx-auto min-h-screen flex flex-col"> | |
<header class="my-8 px-4"> | |
<nav class="flex items-center justify-between mb-4"> | |
// ... lines 20 - 31 | |
<twig:Modal | |
// ... lines 33 - 37 | |
data-action="keydown.meta+k@window->modal#open keydown.ctrl+k@window->modal#open" | |
> | |
// ... lines 40 - 50 | |
</twig:Modal> | |
</nav> | |
</header> | |
// ... lines 54 - 63 | |
</div> | |
// ... lines 65 - 93 | |
</body> | |
</html> |
¡Hora de probar! Me muevo y... ¡teclado! ¡Me encanta! ¡Listo!
Componente vivo de carga perezosa
Ah, ¡y los Componentes Vivos también se pueden cargar perezosamente mediante AJAX! Observa: añade un atributo defer
. Cuando actualicemos, no veremos ninguna diferencia... porque ese componente se oculta al cargar la página de todos modos. Pero en realidad, acaba de cargarse vacío y luego ha hecho inmediatamente una llamada Ajax para cargarse de verdad. Podemos verlo aquí abajo, en la barra de herramientas de depuración web Esta es una forma estupenda de aplazar la carga de algo pesado, para que no ralentice tu página.
No es especialmente útil en nuestro caso porque el componente SearchSite es muy ligero, así que lo eliminaré.
Mañana pasaremos un día más con los Componentes Vivos, esta vez para dotar a un formulario de superpoderes de validación en tiempo real y resolver el viejo y molesto problema de los campos de formulario dinámicos o dependientes.
I got some weird behaviours when using
keydown.ctrl+k@window->modal#open keydown.meta+k@window->modal#open
in Ubuntu+ChromeWhen I press CTRL+k, it opens the search modal and Chrome search bar.
When I press META+k (I suppose it also works with the Windows key), it opens the search modal with a letter k already filled in. Changing to other letters happens the same and triggers the Ubuntu key binding. For instance, META+l would lock the session
I added
:prevent
to both of them to callpreventDefault()
to work correctlykeydown.ctrl+k@window->modal#open:prevent keydown.meta+k@window->modal#open:prevent
https://stimulus.hotwired.dev/reference/actions#options