Ir al contenido
Evaluar mi proyecto
Componentes · IADisponible

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.

ai-modelo.tsx122 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-modelo

Ejemplo de uso de Selector de modelo

Después de copiar ai-modelo.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
models?AiModeloOpcion[]Opcional.
value?stringOpcional.
defaultValue?stringOpcional.
onChange?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor 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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)