11:15 0 0
Construyendo un Componente de ComboBox en Blazor WebAssembly paso a paso

Construyendo un Componente de ComboBox en Blazor WebAssembly paso a paso

  DrUalcman |  septiembre 12024

En muchas preguntas en grupos de Facebook, veo mucha gente que quiere aprender a programar, o que quieren empezar a introducirse en el mundo de Blazor. Entonces cuando necesitan algún componente, llamémoslo, genérico, como la creación de tablas, subida de archivos, editores de texto, u otros que no son muy difíciles de hacer, pero claro, llevan tiempo. Entonces estos programadores "principiantes" se dedican a utilizar librerías de terceros, que estan muy bien y son muy útiles, pero desde mi punto de vista, cuando estas empezando y de verdad quieres aprende a cómo se hacen las cosas, debes de intentar evitar, en la medida de lo posible, librerías de terceros.

No estoy diciendo, ni en insinuando, que no se utilicen. Solo comento que para aprender a cómo se hace un CRUD, no te dedique sólo a aprender como usar, por ejemplo, EntityFramework. Aprende primero SQL, ADO y hazlo a mano. Luego cuando ya sabes el porqué de las cosas, y ya tienes algo de experiencia, pues cambia a utilizar EntityFramework y ahorra tiempo, además de minimizar errores utilizando librerías ya bien probadas. Además, en este caso, se supone que ya sabes como manejar una base de datos desde código, que es un Repositorio y otras muchas teorías que serán muy interesando cuando utilices EntityFramework.

Para el caso de estar aprendiendo Blazor, bueno ahí si que veo mucho más interesante aprender a hacerlo por tí mismo. ¿Y porqué? puede que te preguntes. Pues porque cuando hay progblemas con la librería, si sabes como hacerlo, puedes comprender el porque del problema y que está funcionando mal. Y entonces determinar, si en ese caso específico, no sería mejor hacerlo a mano o hacer que funcione con la librería de terceros.

En las ocasioines que he utilizado librerías de terceros, algunas veces, he tardado más en entender como usar la librería y hacer qeu funcione, que sencillamente hacerlo a mano. Ejemplo, un editor de código que debía subir unas imágenes pero directamente a una base de datos. Un compañero lo consigui hacer funcionar con una librería de terceros, pero cuando algo cambio, no recuerdo si del lado de nuestra API o del lado de la librería, un día dejó de funcionar, el compañero ya no esteaba en el equipo y tras dos días peleando decidí crear mi propio editor. En tan solo 1 día de trabajo tenía lo que yo necesitaba, exactamente como lo necesitaba. En las ocasiones que la API cambio, por requisitos de la aplicación, mi editor no sufrió cambios, y continuó funcionando. Es por esto que, por norma general, no uso librerías de terceros. No es el caso de EntityFramework (que apenes hace 1 año que empiezo a usarlo), pero también tengo mi pequeña librería que hace "casi" lo mismo.

Bueno, como anuncia el tículo vamos a ponernos manos a la obra y vamos a construir un Componente de ComboBox en Blazor WebAssembly. En este tutorial, aprenderás a construir un componente desde cero. Te guiaré a través de cada paso, explicando cómo construir el componente, qué cambios hacer y por qué.

NOTA: Por un problemia con mi editor, los TAG HTML debo de poner un espacio, si no no se muestra correctamente y se renderiza el componente, al igual que en algunos códigos de C# que debeo de separar el < de la siguiente letra. Bueno y así fuerzo a que tengas que escribir y no solo copiar y pegar desde el blog, de ésta forma los conceptos se te quedarán más incrustados en la memoria. Como FRAMEWORK CSS (veis que si uso frameworks de terceros) se ha utilizado Bulma CSS por lo que las clases son de este framework y el resto lo he incluido como estilos directos solo para que se entienda, lo ideal es tener tus propias clases CSS.

1. Estructura Inicial del Componente (Solo HTML)

Primero vamos a presentar lo que es el HTML que pretendemos muestre una lista de opciones, que aparecen cuando se va escribiendo en un cuadro de texto, como se hace habitualmente en un ComboBox de WindowsForm. Este HTML es solo la base visual. Luego, agregaremos la lógica para hacer que el componente funcione.

Agregamos un nuevo elemento de tipo Compomente Razor y lo vamos a llamar ComboBox.

< div class="control has-icons-right">
< input type="text" class="input" placeholder="Search..." / >
< span class="icon is-right">
< i class="fas fa-angle-down">< /i>
< /span>
< div class="dropdown-content" style="position: absolute; z-index: 1; background-color: white; width: 100%;">
< span class="dropdown-item">Item 1< /span>
< span class="dropdown-item">Item 2< /span>
< span class="dropdown-item">Item 3< /span>
< /div>
< /div>

< input >: Campo de texto para la entrada del usuario.
< span class="icon is-right">: Icono que indica que el campo es un ComboBox desplegable.
< div class="dropdown-content">: Contenedor para los elementos desplegables. Se le ha agregado un estilo en lína (recomendado crear nuestra clase personalizada) para podicionar y poner el fondo de color blanco.

Este código HTML muestra un cuadro de texto con un ícono de flecha hacia abajo, y una lista desplegable que contiene tres elementos ("Item 1", "Item 2", "Item 3"). El objetivo es que esta lista se actualice automáticamente a medida que escribas en el cuadro de texto.

1.1. Definir el componente como genérico

Vamos a comenzar por definir el componente en Blazor. Para esto, utilizaremos Razor, que es el lenguaje de marcado que Blazor usa para combinar HTML y C#. Definir la clase-componente genérica: Vamos a utilizar una clase genérica, lo que significa que podemos definir qué tipo de datos manejará nuestro componente. Por ejemplo, podríamos usarlo con una lista de números, cadenas de texto, o incluso objetos más complejos. Para ello escribiremos al principio del componente la siguiente línea de código.

@typeparam TItem
¿Por qué usamos una clase genérica? Una clase genérica nos permite reutilizar este componente para cualquier tipo de datos. Por ejemplo, si queremos mostrar una lista de usuarios, productos o cualquier otro tipo de datos, no tenemos que crear un componente nuevo para cada uno.

1.2. HTML del componente

En este punto básicamente escribiremos el HTML del paso anterior a continuación del tipo de datos. Por lo que el código de nuestro componente, por ahora, del lado del front end nos queda como sigue

@typeparam TItem
< div class="control has-icons-right">
< input type="text" class="input" placeholder="Search..." />
< span class="icon is-right">
< i class="fas fa-angle-down">< /i>
< /span>
< div class="dropdown-content" style="position: absolute; z-index: 1; background-color: white; width: 100%;">
< span class="dropdown-item">Item 1< /span>
< span class="dropdown-item">Item 2< /span>
< span class="dropdown-item">Item 3< /span>
< /div>
< /div>

2. Agregar Lógica al Backend

En esta sección, vamos a convertir la estructura HTML en un componente funcional en Blazor agregando la lógica de backend en C#. Para ello, lo más rápido es hacer click con el botón derecho, o culsar ctrl+., y seleccionar Extrar a un archivo de codigo en inglés se muestra como Extrat block to code behind.

Ahora que tenemos la estructura HTML, agreguemos la lógica del backend. Este código en C# se encargará de manejar los datos, filtrar los elementos según lo que escribas y controlar cuándo mostrar la lista desplegable.

2.1. Definir la Clase como Genérica

Primero, definimos la clase del componente ComboBox como una clase genérica. Esto nos permite manejar diferentes tipos de datos.

public partial class ComboBox< TItem>

public partial class ComboBox< TItem>: Clase genérica ComboBox que acepta un tipo de dato TItem.

2.2. Definir propiedades y variables

Primero, definimos la clase del componente ComboBox como una clase genérica. Esto nos permite manejar diferentes tipos de datos.

[Parameter] public IEnumerable< TItem> Items { get; set; }
[Parameter] public Func< Task< IEnumerable< TItem>>> DataSource { get; set; }
[Parameter] public Func< TItem, string> DisplayProperty { get; set; }
[Parameter] public EventCallback< TItem> OnItemSelected { get; set; }

[Parameter]: Atributo que permite que estas propiedades se configuren desde un componente padre.
IEnumerable< TItem> Items: Lista de elementos para mostrar en el ComboBox.
Func< Task< IEnumerable< TItem>>> DataSource: Es una función opcional para obtener los elementos de forma asíncrona.
Func< TItem, string> DisplayProperty: Función que define cómo mostrar cada elemento.
EventCallback< TItem> OnItemSelected: Evento que se invoca cuando se selecciona un elemento.

private IEnumerable< TItem> _items;
private List< TItem> FilteredItems { get; set; } = new List();
private string SearchText { get; set; } = string.Empty;
private bool IsOpen { get; set; }
private bool _isItemSelected;
private bool _isInitialized;

_items: Es la lista de elementos sin modificar para obtener cuáles se mostrarán en el ComboBox.
FilteredItems: Es la lista de elementos que coinciden con el texto que el usuario ha escrito la cual se utilizará para mostrar los elementos en la lista desplegable del componente.
SearchText: Es el texto que el usuario escribe en el cuadro de texto.
IsOpen: Controla si la lista desplegable está abierta o cerrada.
_isInitialized: Controla que el componente no trate de inicializar 2 veces las variables de trabajo.
_isItemSelected: Controla cuando un elemento ya esta seleccionado para devolver el valor correcto.

2.2. Inicializar el Componente

 Cuando el componente se carga por primera vez, necesitamos obtener los elementos ya sea desde la propiedad Items o usando DataSource. Por ello de la necesidad de la varialbe _isInitialized ya que en la vida del componente podría darse el caso de que intente obtener los datos de forma asíncrona y desde el parámetro Items. Por ello debemos de controlar 2 coas:

1 - Si hay un DataSource
2 - Si hay un Items

    protected override async Task OnInitializedAsync()
{
if (!_isInitialized)
{
_isInitialized = true;
await LoadItemsAsync();
}
}

protected override async Task OnParametersSetAsync()
{
if (!_isInitialized && DataSource != null)
await LoadItemsAsync();
else if (_isInitialized && Items != _items)
{
_items = Items;
FilteredItems = _items.ToList();
}
}

private async Task LoadItemsAsync()
{
if (DataSource != null)
_items = await DataSource();
else
_items = Items;
FilteredItems = _items?.ToList() ?? new List();
}

Este método LoadItemsAsync se encarga de cargar los elementos. Si DataSource está definido, se usan esos elementos, de lo contrario, se usan los de la propiedad Items.

2.3. Actualización en el HTML:

Después de agregar la lógica de inicialización, necesitamos hacer que el HTML se alinee con estas propiedades. Aún no hemos agregado eventos o enlaces, pero el HTML se ajustará una vez que se añadan las funcionalidades. Por lo que nuestro componente Razor cambia como se muestra a continuación:

< div class="control has-icons-right">
< input type="text" class="input" @bind="SearchText" placeholder="Search..." />
< span class="icon is-right">
< i class="fas fa-angle-down">
< /span>
@if (IsOpen)
{
< div class="dropdown-content" style="position: absolute; z-index: 1; background-color: white; width: 100%;">
@foreach (TItem item in FilteredItems)
{
< span class="dropdown-item">Item < /span>
}
< /div>
}
< /div>

Con esto ya tenemos lo má básico del componente terminado. Solo queda darle funcionalidad, para ello vamos a continuar con un poco más de backend y actualizando luego nuestro Razor con los enlaces a los manejadores de eventos necesarios. Por ahora solo hemos enlazado los datos de entrada a las variables correspondientes.

3. Manejo de Eventos. Filtrado y selección de elementos

Ahora que los elementos están cargados, necesitamos implementar la lógica para filtrar la lista según lo que el usuario escriba y permitir la selección de un elemento.

3.1. Filtrar los elementos

Este método filtra los elementos según el texto que el usuario ha escrito en el cuadro de búsqueda.

private void FilterItems(ChangeEventArgs e = null)
{
SearchText = e?.Value?.ToString() ?? SearchText;
FilteredItems = string.IsNullOrWhiteSpace(SearchText)
? _items.ToList()
: _items.Where(item => GetDisplayValue(item).Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToList();
}

private string GetDisplayValue(TItem item) => DisplayProperty?.Invoke(item) ?? item.ToString();

FilterItems: Filtra los elementos basados en el texto ingresado por el usuario.
ChangeEventArgs e: Permite obtener el nuevo valor del campo de texto.
GetDisplayValue(item).Contains(SearchText): Verifica si el texto del elemento contiene el texto de búsqueda.

¿Cómo funciona? Si el texto de búsqueda está vacío, mostramos todos los elementos. Si no, filtramos los elementos que contienen el texto ingresado, sin importar si es mayúscula o minúscula.

Nos apollamos de un nuevo método GetDisplayValue(TItem item) que lo úinico que hace es devolver el valor que hay que mostrar, ejecutando el código que nos hayan proporcionado para obtener el nombre del elemento, y si no lo hay, asumimos que el elemento en sí es el nombre a mostrar, por ejemplo que recibieramos una simple lista de valores de cadenas como ciudades.

3.2. Seleccionar un elemento

Este método se llama cuando el usuario selecciona un elemento de la lista.

private void SelectItem(TItem item)
{
SearchText = GetDisplayValue(item);
_isItemSelected = true;
OnItemSelected.InvokeAsync(item);
FilterItems();
CloseDropdown();
}

private void CloseDropdown() => IsOpen = false;

SelectItem: Maneja la selección de un elemento.

¿Qué hace? Actualiza el cuadro de texto con el valor del elemento seleccionado, marca que un elemento ha sido seleccionado y cierra la lista desplegable.

En ésta caso nos apollamos de los dós métodos anteriores para mostrar el texto y filtrar la lista por elemento seleccionado. Además hacemos uso de un nuevo método para poder cierrar la lista desplegable una vez seleccionado un elemento.

3.3. Actualizar el componente Razor

Ahora vamos a actualizar el componente Razor con estos manejadores de eventos y darle algo más de funcionalidad al componente.
< div class="control has-icons-right" >
< input type="text" class="input" @bind="SearchText" @oninput="FilterItems" placeholder="Search..." />
< span class="icon is-right">
< i class="fas fa-angle-down">< /i>
< /span>
@if (IsOpen)
{
< div class="dropdown-content" style="position: absolute; z-index: 1; background-color: white; width: 100%;">
@foreach (TItem item in FilteredItems)
{
< span class="dropdown-item" @onmousedown="() => SelectItem(item)">
@GetDisplayValue(item)
< /span>
}
< /div>
}
< /div>

Se ha utilizado el evneto @oninput para enlacer el evento para que cada vez que haya un cambio en la entrada se actualice la UI, por defecto el evento es @onchange y sólo se vería el cambio en la lista al perder el foco o salir del input que es cuando se dispara/invoca el evento @onchange.

4. Controlar el comportamiento del dropdown

Finalmente, agregamos la lógica para controlar cuándo abrir o cerrar la lista desplegable. Se pretende que cada vez que se haga click dentro del cuadro de texto se abra la lista desplegable y cuando se salga o se pierda el foco entonces se cierre.

4.1. Manejar la Pérdida de Foco

Para lograr el efecto deseado necesitamos manejar un nuevo evento @onfocusout, ya que manejamos la apertura cuando se obtiene el foco en el cuadro de texto.

private void HandleFocusOut()
{
if (ShouldSelectSingleItem()) SelectItem(FilteredItems.First());
else if (ShouldSelectExactMatch()) SelectExactMatch();
ResetItemSelection();
CloseDropdown();
}

private void SelectExactMatch()
{
TItem match = FilteredItems.FirstOrDefault(item => GetDisplayValue(item).Equals(SearchText, StringComparison.OrdinalIgnoreCase));
if (match != null) SelectItem(match);
}
private bool ShouldSelectSingleItem() => !isItemSelected && FilteredItems.Count == 1;
private bool ShouldSelectExactMatch() => !isItemSelected && FilteredItems.Count > 1;
private void ResetItemSelection() => isItemSelected = false;
private void OpenDropdown() => IsOpen = true;

HandleFocusOut: Maneja la lógica cuando el campo pierde el foco. Verifica si debe seleccionar un único elemento o buscar una coincidencia exacta.
SelectExactMatch: Busca la primera concordancia exacta de la lista.
ShouldSelectSingleItem: Verifica si hay un solo elemento disponible para seleccionar.
ShouldSelectExactMatch: Verifica si hay elementos disponibles que coincidan exactamente con el texto.
ResetItemSelection: Restablece el estado de selección del ítem.

Aquí controlamos si debemos seleccionar automáticamente un elemento cuando el usuario hace clic fuera del cuadro de texto. Si hay solo un elemento en la lista filtrada, lo seleccionamos. Si hay un elemento que coincide exactamente con lo que se escribió, también lo seleccionamos.

4.2. Actualizar el componente Razor

Ya por último debemos de volver a actualizar el componente Razor con los manejadores de eventos necesarios
< div class="control has-icons-right" @onfocusout="HandleFocusOut">
< input type="text" class="input" @bind="SearchText" @oninput="FilterItems" placeholder="Search..." @onfocus="OpenDropdown" />
< span class="icon is-right">
< i class="fas fa-angle-down">< /i>
< /span>
@if (IsOpen)
{
< div class="dropdown-content" style="position: absolute; z-index: 1; background-color: white; width: 100%;">
@foreach (TItem item in FilteredItems)
{
< span class="dropdown-item" @onmousedown="() => SelectItem(item)">
@GetDisplayValue(item)
< /span>
}
< /div>
}
< /div>

Conclusión

Hemos construido un componente de ComboBox paso a paso, desde la estructura inicial en HTML hasta la integración completa con la lógica en C#. Este enfoque modular te permite entender y construir el componente en partes, asegurando que comprendas cada paso del proceso.

¡Espero que esta guía te haya sido útil y que ahora te sientas más cómodo trabajando con componentes en Blazor!

Archivo ComboBox.razor

@typeparam TItem

< div class="control has-icons-right" @onfocusout="HandleFocusOut">
< input type="text" class="input" @bind="SearchText" @oninput="FilterItems" placeholder="Search..." @onfocus="OpenDropdown" />
< span class="icon is-right">
< i class="fas fa-angle-down">< /i>
< /span>
@if (IsOpen)
{
< div class="dropdown-content" style="position: absolute; z-index: 1; background-color: white; width: 100%;">
@foreach (TItem item in FilteredItems)
{
< span class="dropdown-item" @onmousedown="() => SelectItem(item)">
@GetDisplayValue(item)
< /span>
}
< /div>
}
< /div>

Archivo ComboBox.razor.cs

public partial class ComboBox< TItem>
{
[Parameter] public IEnumerable< TItem> Items { get; set; }
[Parameter] public Func< Task< IEnumerable< TItem>>> DataSource { get; set; }
[Parameter] public Func< TItem, string> DisplayProperty { get; set; }
[Parameter] public EventCallback< TItem> OnItemSelected { get; set; }


private IEnumerable< TItem> _items;
private List< TItem> FilteredItems { get; set; } = new List();
private string SearchText { get; set; } = string.Empty;
private bool IsOpen { get; set; }
private bool _isItemSelected;
private bool _isInitialized;

protected override async Task OnInitializedAsync()
{
if (!_isInitialized)
{
_isInitialized = true;
await LoadItemsAsync();
}
}

protected override async Task OnParametersSetAsync()
{
if (!_isInitialized && DataSource != null)
await LoadItemsAsync();
else if (_isInitialized && Items is not null && Items != _items)
{
_items = Items;
FilteredItems = _items.ToList();
}
}

private async Task LoadItemsAsync()
{
if (DataSource != null)
_items = await DataSource();
else
_items = Items;
FilteredItems = _items?.ToList() ?? new List();
}

private void HandleFocusOut()
{
if (ShouldSelectSingleItem()) SelectItem(FilteredItems.First());
else if (ShouldSelectExactMatch()) SelectExactMatch();
ResetItemSelection();
CloseDropdown();
}

private bool ShouldSelectSingleItem() => !_isItemSelected && FilteredItems.Count == 1;

private bool ShouldSelectExactMatch() => !_isItemSelected && FilteredItems.Count > 1;

private void ResetItemSelection() => _isItemSelected = false;

private void FilterItems(ChangeEventArgs e = null)
{
SearchText = e?.Value?.ToString() ?? SearchText;
FilteredItems = string.IsNullOrWhiteSpace(SearchText)
? _items.ToList()
: _items.Where(item => GetDisplayValue(item).Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToList();
}

private void SelectItem(TItem item)
{
SearchText = GetDisplayValue(item);
_isItemSelected = true;
OnItemSelected.InvokeAsync(item);
FilterItems();
CloseDropdown();
}

private void SelectExactMatch()
{
TItem match = FilteredItems.FirstOrDefault(item => GetDisplayValue(item).Equals(SearchText, StringComparison.OrdinalIgnoreCase));
if (match != null) SelectItem(match);
}

private string GetDisplayValue(TItem item) => DisplayProperty?.Invoke(item) ?? item.ToString();

private void CloseDropdown() => IsOpen = false;
private void OpenDropdown() => IsOpen = true;
}

Actualizaciones

2024-09-03: Se modifica el metodo OnParameterSetAsync para agregar una comparacion mas en el else if


0 Comentarios

 
 
 

Archivo