Selector de modelo
Listbox accesible con descripción, velocidad y capacidad de cada opción. Nombres genéricos de muestra.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
/** Selector de modelo para un asistente: listbox accesible con descripción, velocidad y capacidad de cada opción. Nombres genéricos de muestra. React + TypeScript, sin dependencias. Personaliza con --gcai-*. */
export type AiModeloOpcion = { id: string; name: string; desc: string; speed: number; depth: number };
export type AiModeloProps = {
models?: AiModeloOpcion[];
value?: string;
defaultValue?: string;
onChange?: (id: string) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcaimd-root { box-sizing: border-box; position: relative; display: inline-block; width: 100%; max-width: 360px; color: var(--gcai-ink, #ededee); font: 400 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcaimd-root *, .gcaimd-root *::before, .gcaimd-root *::after { box-sizing: border-box; }
.gcaimd-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcaimd-root :focus-visible { outline: 2px solid var(--gcai-accent, #84b3ff); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcaimd-root * { animation: none !important; transition-duration: .01ms !important; } }
.gcaimd-btn { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 44px; padding: 0 12px 0 14px; border: 1px solid var(--gcai-line, #26272c); border-radius: 10px; background: var(--gcai-surface, #111113); color: inherit; cursor: pointer; text-align: left; transition: border-color .15s; }
.gcaimd-btn:hover { border-color: #3a3b42; }
.gcaimd-btn small { display: block; color: var(--gcai-muted, #9b9ca3); font-size: 12px; }
.gcaimd-btn svg { transition: transform .2s; color: var(--gcai-muted, #9b9ca3); }
.gcaimd-btn[aria-expanded="true"] svg { transform: rotate(180deg); }
.gcaimd-list { position: absolute; z-index: 5; left: 0; right: 0; bottom: calc(100% + 6px); margin: 0; padding: 6px; list-style: none; border: 1px solid var(--gcai-line, #26272c); border-radius: 12px; background: #131316; box-shadow: 0 16px 32px -12px rgba(0, 0, 0, .7); animation: gcaimd-in .15s ease-out; }
.gcaimd-opt { display: grid; gap: 6px; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
.gcaimd-opt[data-active="true"] { background: var(--gcai-well, #1a1b1e); }
.gcaimd-opt[aria-selected="true"] .gcaimd-name { color: var(--gcai-accent, #84b3ff); }
.gcaimd-name { display: flex; align-items: center; justify-content: space-between; font-weight: 600; }
.gcaimd-desc { color: var(--gcai-muted, #9b9ca3); font-size: 13px; }
.gcaimd-meters { display: flex; gap: 16px; font-size: 11px; color: var(--gcai-muted, #9b9ca3); }
.gcaimd-meter { display: flex; align-items: center; gap: 6px; }
.gcaimd-meter i { display: flex; gap: 2px; }
.gcaimd-meter b { width: 12px; height: 4px; border-radius: 2px; background: #2c2d33; }
.gcaimd-meter b[data-on="true"] { background: var(--gcai-accent, #84b3ff); }
@keyframes gcaimd-in { from { opacity: 0; transform: translateY(4px); } }`;
const DEFAULT: AiModeloOpcion[] = [
{ id: "rapido", name: "Rápido", desc: "Respuestas inmediatas para tareas cotidianas.", speed: 5, depth: 2 },
{ id: "equilibrado", name: "Equilibrado", desc: "Buen punto medio entre velocidad y calidad.", speed: 3, depth: 3 },
{ id: "profundo", name: "Profundo", desc: "Razona con más detalle problemas complejos.", speed: 1, depth: 5 },
];
const Meter = ({ n, label }: { n: number; label: string }) => (
<span className="gcaimd-meter">
{label}
<i aria-label={`${n} de 5`}>
{[1, 2, 3, 4, 5].map((i) => (
<b key={i} data-on={i <= n} />
))}
</i>
</span>
);
export function AiModelo({ models = DEFAULT, value, defaultValue, onChange, className = "", style }: AiModeloProps) {
const id = useId();
const [inner, setInner] = useState(defaultValue ?? models[1]?.id ?? models[0].id);
const current = value ?? inner;
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const root = useRef<HTMLDivElement>(null);
const btn = useRef<HTMLButtonElement>(null);
const selected = models.find((model) => model.id === current) ?? models[0];
useEffect(() => {
if (!open) return;
const away = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); };
document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away);
}, [open]);
function choose(index: number) {
setInner(models[index].id);
onChange?.(models[index].id);
setOpen(false);
btn.current?.focus();
}
function key(event: KeyboardEvent) {
if (!open) {
if (["ArrowUp", "ArrowDown", "Enter", " "].includes(event.key)) { event.preventDefault(); setActive(Math.max(0, models.findIndex((m) => m.id === current))); setOpen(true); }
return;
}
if (event.key === "ArrowDown") { event.preventDefault(); setActive((v) => Math.min(models.length - 1, v + 1)); }
else if (event.key === "ArrowUp") { event.preventDefault(); setActive((v) => Math.max(0, v - 1)); }
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(active); }
else if (event.key === "Escape" || event.key === "Tab") setOpen(false);
}
return (
<div ref={root} className={`gcaimd-root ${className}`} style={style}>
<style>{CSS}</style>
{open ? (
<ul className="gcaimd-list" id={`${id}-l`} role="listbox" aria-label="Modelo">
{models.map((model, index) => (
<li key={model.id} id={`${id}-o${index}`} className="gcaimd-opt" role="option" aria-selected={model.id === current} data-active={index === active ? "true" : undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(index)}>
<span className="gcaimd-name">
{model.name}
{model.id === current ? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5" /></svg> : null}
</span>
<span className="gcaimd-desc">{model.desc}</span>
<span className="gcaimd-meters">
<Meter n={model.speed} label="Velocidad" />
<Meter n={model.depth} label="Capacidad" />
</span>
</li>
))}
</ul>
) : null}
<button ref={btn} className="gcaimd-btn" type="button" role="combobox" aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? `${id}-l` : undefined} aria-activedescendant={open ? `${id}-o${active}` : undefined} onClick={() => { setActive(Math.max(0, models.findIndex((m) => m.id === current))); setOpen((value) => !value); }} onKeyDown={key}>
<span>
<small>Modelo</small>
{selected.name}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
</button>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-modeloEjemplo de uso de Selector de modelo
Después de copiar ai-modelo.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { AiModelo } from "./ai-modelo";
export default function Ejemplo() {
return <AiModelo value="Ejemplo" />;
}Propiedades de AiModelo
AiModelo acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
models? | AiModeloOpcion[] | Opcional. |
value? | string | Opcional. |
defaultValue? | string | Opcional. |
onChange? | (id: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Selector de modelo con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gcai-ink | #ededee |
--gcai-accent | #84b3ff |
--gcai-line | #26272c |
--gcai-surface | #111113 |
--gcai-muted | #9b9ca3 |
--gcai-well | #1a1b1e |
Accesibilidad y rendimiento
El archivo tiene 122 líneas (7 KB) y define 1 animación con @keyframes. Esto es lo que hace Selector de modelo por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: listbox, option, combobox.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".