Ir al contenido

Componentes · Ola 3 · Formularios

Combobox

Elige una opción de una lista larga escribiendo para filtrarla. Tiene el aspecto de línea de Input; la lista es la superficie de Menu. Dentro de un Field toma de él su etiqueta, su descripción y sus estados.

import { Combobox } from '@archeblack/ui';

Ejemplos

Dentro de un Field

Escribir filtra la lista sin distinguir mayúsculas ni tildes. El valor es el de la opción elegida, con bind:value.

Escribe «peru» o «espana»: no importan las tildes.

Svelte
<script lang="ts">
	import { Combobox, Field } from '@archeblack/ui';

	const countries = [
		{ value: 'ar', label: 'Argentina' },
		{ value: 'bo', label: 'Bolivia' },
		{ value: 'br', label: 'Brasil' },
		{ value: 'cl', label: 'Chile' },
		{ value: 'co', label: 'Colombia' },
		{ value: 'es', label: 'España' },
		{ value: 'mx', label: 'México' },
		{ value: 'pe', label: 'Perú' },
		{ value: 'uy', label: 'Uruguay' }
	];

	let country = $state('');
</script>

<div style:width="min(100%, 20rem)">
	<Field label="País" hint="Escribe «peru» o «espana»: no importan las tildes.">
		<Combobox items={countries} bind:value={country} placeholder="Busca un país" />
	</Field>
</div>

Grupos y botón para quitar

Grupos con etiqueta, una opción deshabilitada y clearable, que muestra una x mientras hay una elección.

París no está disponible en tu plan.

Svelte
<script lang="ts">
	import { Combobox, Field, type ComboboxItem } from '@archeblack/ui';

	const zones: ComboboxItem[] = [
		{
			label: 'América',
			options: [
				{ value: 'America/Argentina/Buenos_Aires', label: 'Buenos Aires' },
				{ value: 'America/Bogota', label: 'Bogotá' },
				{ value: 'America/Mexico_City', label: 'Ciudad de México' },
				{ value: 'America/Santiago', label: 'Santiago' }
			]
		},
		{
			label: 'Europa',
			options: [
				{ value: 'Europe/Lisbon', label: 'Lisboa' },
				{ value: 'Europe/Madrid', label: 'Madrid' },
				{ value: 'Europe/Paris', label: 'París', disabled: true }
			]
		}
	];

	let zone = $state('America/Argentina/Buenos_Aires');
</script>

<div style:width="min(100%, 20rem)">
	<Field label="Zona horaria" hint="París no está disponible en tu plan.">
		<Combobox items={zones} bind:value={zone} clearable placeholder="Busca una ciudad" />
	</Field>
</div>

Filtro propio

filter decide qué opciones coinciden; emptyText cambia el mensaje cuando no queda ninguna.

Escribe el código (usd) o el comienzo de una palabra (peso).

Svelte
<script lang="ts">
	import { Combobox, Field, type ComboboxFilter } from '@archeblack/ui';

	const currencies = [
		{ value: 'ARS', label: 'Peso argentino' },
		{ value: 'BRL', label: 'Real brasileño' },
		{ value: 'CLP', label: 'Peso chileno' },
		{ value: 'EUR', label: 'Euro' },
		{ value: 'MXN', label: 'Peso mexicano' },
		{ value: 'USD', label: 'Dólar estadounidense' }
	];

	// Busca por el código (ARS) o por el comienzo de cualquier palabra del nombre.
	const byCodeOrWord: ComboboxFilter = (option, query) => {
		const text = query.trim().toLowerCase();
		return (
			option.value.toLowerCase().startsWith(text) ||
			option.label
				.toLowerCase()
				.split(' ')
				.some((word) => word.startsWith(text))
		);
	};

	let currency = $state('');
</script>

<div style:width="min(100%, 20rem)">
	<Field label="Moneda" hint="Escribe el código (usd) o el comienzo de una palabra (peso).">
		<Combobox
			items={currencies}
			bind:value={currency}
			filter={byCodeOrWord}
			emptyText="Ninguna moneda coincide"
			placeholder="Busca una moneda"
		/>
	</Field>
</div>

Tallas

sm, md y lg, por alto, como Input y Select. Sin Field, el nombre va en aria-label.

Svelte
<script lang="ts">
	import { Combobox } from '@archeblack/ui';

	const plans = [
		{ value: 'free', label: 'Gratis' },
		{ value: 'team', label: 'Equipo' },
		{ value: 'business', label: 'Empresa' }
	];
</script>

<div style:display="grid" style:gap="var(--arche-spacing-6)" style:width="min(100%, 20rem)">
	<Combobox items={plans} size="sm" placeholder="Talla sm, 28 px" aria-label="Plan, talla sm" />
	<Combobox items={plans} placeholder="Talla md, 36 px" aria-label="Plan, talla md" />
	<Combobox items={plans} size="lg" placeholder="Talla lg, 44 px" aria-label="Plan, talla lg" />
</div>

Estados

Inválido, con el mensaje del Field, y deshabilitado.

Elige quién responde por el proyecto.

Se fija al crear la cuenta.

Svelte
<script lang="ts">
	import { Combobox, Field } from '@archeblack/ui';

	const owners = [
		{ value: 'ana', label: 'Ana Duarte' },
		{ value: 'bruno', label: 'Bruno Silva' },
		{ value: 'carla', label: 'Carla Ortiz' }
	];

	let owner = $state('');
</script>

<div style:display="grid" style:gap="var(--arche-spacing-6)" style:width="min(100%, 20rem)">
	<Field label="Responsable" error="Elige quién responde por el proyecto.">
		<Combobox items={owners} bind:value={owner} placeholder="Busca una persona" />
	</Field>
	<Field label="Organización" hint="Se fija al crear la cuenta." disabled>
		<Combobox items={[{ value: 'arche', label: 'Arche' }]} value="arche" />
	</Field>
</div>

En un formulario

Obligatorio, con el error al enviar y el foco en el primer campo inválido. Con name, el formulario envía el valor de la opción, no el texto.

Los campos con * son obligatorios.

Svelte
<script lang="ts">
	import { tick } from 'svelte';
	import { Button, Combobox, Field, Input } from '@archeblack/ui';

	const cities = [
		{
			label: 'Argentina',
			options: [
				{ value: 'bue', label: 'Buenos Aires' },
				{ value: 'cor', label: 'Córdoba' },
				{ value: 'mdz', label: 'Mendoza' },
				{ value: 'ros', label: 'Rosario' }
			]
		},
		{
			label: 'Uruguay',
			options: [
				{ value: 'mvd', label: 'Montevideo' },
				{ value: 'pde', label: 'Punta del Este' }
			]
		}
	];

	let name = $state('');
	let city = $state('');
	let submitted = $state(false);
	let saved = $state(false);

	// Los errores aparecen después del primer envío, no mientras la persona completa el formulario.
	const errors = $derived({
		name: name.trim() ? undefined : 'Escribe el nombre de la sede.',
		city: city ? undefined : 'Elige una ciudad de la lista.'
	});
	const errorOf = (field: keyof typeof errors) => (submitted ? errors[field] : undefined);

	async function submit(event: SubmitEvent) {
		event.preventDefault();
		const form = event.currentTarget as HTMLFormElement;
		submitted = true;
		saved = !Object.values(errors).some(Boolean);
		if (!saved) {
			// Espera a que los campos muestren su error y lleva el foco al primero.
			await tick();
			form.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
		}
	}
</script>

<form
	novalidate
	onsubmit={submit}
	style:display="grid"
	style:gap="var(--arche-spacing-6)"
	style:width="min(100%, 24rem)"
>
	<p style:margin="0" style:color="var(--arche-color-text-muted)">
		Los campos con * son obligatorios.
	</p>
	<Field label="Nombre de la sede" required error={errorOf('name')}>
		<Input bind:value={name} autocomplete="off" />
	</Field>
	<Field label="Ciudad" required error={errorOf('city')}>
		<Combobox items={cities} bind:value={city} name="city" placeholder="Busca una ciudad" />
	</Field>
	<div style:display="flex" style:align-items="center" style:gap="var(--arche-spacing-4)">
		<Button type="submit" variant="primary">Crear sede</Button>
		<span role="status" style:color="var(--arche-color-text-muted)">
			{saved ? 'Sede lista para crear.' : ''}
		</span>
	</div>
</form>

Props

Props de Combobox
PropDescripción
items ComboboxItem[] Las opciones: { value, label, disabled? } sueltas o en grupos { label, options }. Cada value es único en toda la lista; label es lo que se ve, lo que filtra la búsqueda y lo que queda escrito al elegir.
bind:value? string Valor de la opción elegida, o '' si no hay ninguna. Borrar todo el texto del campo quita la elección.
bind:open? boolean Por defecto falseSi la lista está abierta.
onValueChange? (value: string) => void Se llama con el valor nuevo, después de actualizar value y solo si el cambio se aceptó (un bind:value de función puede rechazarlo).
onOpenChange? (open: boolean) => void Se llama al abrir o cerrar la lista, después de actualizar open y solo si el cambio se aceptó.
filter? (option: ComboboxOption, query: string) => boolean Filtro propio. Recibe cada opción y el texto tal como se escribió (nunca vacío). Por defecto, la etiqueta contiene el texto sin distinguir mayúsculas ni tildes.
emptyText? string Por defecto 'Sin resultados'Mensaje de la lista cuando ninguna opción coincide. También se anuncia.
clearable? boolean Por defecto falseMuestra un botón con una x para quitar la elección mientras hay una.
clearLabel? string Por defecto 'Quitar la elección'Nombre accesible del botón para quitar la elección.
toggleLabel? string Por defecto 'Mostrar opciones'Nombre accesible del chevron, que abre y cierra la lista.
size? 'sm' | 'md' | 'lg' Por defecto 'md'Talla por alto: sm 28 px (texto de 13 px), md 36 px y lg 44 px, como Input y Select.
invalid? boolean Por defecto falseMarca el valor como inválido (aria-invalid). Dentro de un Field lo decide su error.
name? string Nombre del campo en el formulario. Se envía el value de la opción elegida (en un input oculto), no el texto.
bind:ref? HTMLInputElement | null El <input>, para enfocarlo desde el código.
class? ClassValue Clases del producto; se suman a arche-combobox, el contenedor que dibuja la línea.
...rest HTMLInputAttributes Cualquier otro atributo va al <input>: placeholder, required, disabled, aria-label, oninput, onkeydown… Sin type, role, list ni autocomplete: los decide el componente.

Accesibilidad

  • Sigue el patrón combobox con lista y autocompletado de APG: el foco se queda en el campo y la opción activa se señala con aria-activedescendant. El campo tiene role="combobox", aria-autocomplete="list", aria-expanded y, abierto, aria-controls hacia la lista.
  • Teclado: escribir filtra y abre la lista; la flecha abajo (o Alt + flecha abajo) la abre y recorre las opciones; flecha arriba, Inicio, Fin, Re Pág y Av Pág también; Enter elige la opción resaltada; Escape cierra la lista y, cerrada, borra el campo y la elección; Tab cierra la lista y sigue.
  • La opción resaltada con el teclado lleva un aro de foco hacia adentro, además del fondo; con el puntero, solo el fondo. La elegida lleva un check y aria-selected="true"; las deshabilitadas se ven y se anuncian (aria-disabled="true"), pero no se eligen.
  • El chevron y el botón de quitar son botones con nombre (toggleLabel, clearLabel) fuera del orden de tabulación, como en APG: el campo es una sola parada de Tab y con el teclado se hace lo mismo con las flechas y Escape.
  • Necesita un nombre: dentro de un Field lo da la etiqueta; suelto, aria-label, aria-labelledby o un <label for> propio. La lista toma el mismo nombre. En desarrollo, un Combobox suelto sin nombre avisa por consola.
  • Cuando ninguna opción coincide, emptyText se ve en la lista y se anuncia por una región de estado (role="status").
  • Los grupos son role="group" con su etiqueta como nombre.
  • La línea llega a 3:1 contra toda capa. El foco y la lista abierta la encienden; inválida y con foco, la línea toma el color de foco y el halo es de peligro. En colores forzados la línea se engrosa y la opción resaltada toma los colores de selección del sistema.

Qué evitar

  • Un Combobox con cinco o seis opciones. Un Select o un grupo de Radio: se ven sin escribir.
  • Usarlo como buscador libre, donde el texto escrito es el valor. Un Input type="search". El Combobox solo acepta opciones de la lista.
  • Cambiar la página o enviar el formulario al elegir una opción. Aplica la elección con un botón: con el teclado se recorren las opciones una a una.
  • Etiquetas repetidas o que solo se distinguen por el valor. Etiquetas únicas y completas («Córdoba, Argentina» y «Córdoba, España»): es lo que se lee y lo que queda escrito.