aegida-console / components / chat / model-selector.tsx
model-selector.tsx
Raw
"use client";

import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import { AUTO_MODEL, type ChatModel } from "@/lib/chat/models";
import type { ModelId } from "@/lib/chat/types";

type ModelSelectorProps = {
  models: ChatModel[];
  modelId: ModelId;
  onChange: (id: ModelId) => void;
};

export function ModelSelector({ models, modelId, onChange }: ModelSelectorProps) {
  const [open, setOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const selected =
    models.find((model) => model.id === modelId) ??
    models.find((model) => model.id === "auto") ??
    models[0] ??
    AUTO_MODEL;

  useEffect(() => {
    if (!open) return;

    function handlePointerDown(event: PointerEvent) {
      if (!containerRef.current?.contains(event.target as Node)) {
        setOpen(false);
      }
    }

    function handleKeyDown(event: globalThis.KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        setOpen(false);
        triggerRef.current?.focus();
        return;
      }

      if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
      event.preventDefault();
      const items = itemRefs.current.filter((item): item is HTMLButtonElement => !!item);
      if (!items.length) return;
      const current = items.indexOf(document.activeElement as HTMLButtonElement);
      const next =
        event.key === "Home"
          ? 0
          : event.key === "End"
            ? items.length - 1
            : event.key === "ArrowDown"
              ? (current + 1) % items.length
              : (current <= 0 ? items.length : current) - 1;
      items[next]?.focus();
    }

    document.addEventListener("pointerdown", handlePointerDown);
    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown);
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [open]);

  return (
    <div className="relative" ref={containerRef}>
      <Button
        aria-expanded={open}
        aria-haspopup="menu"
        aria-label="Выбрать модель"
        className="min-w-32 justify-between"
        onClick={() => setOpen((current) => !current)}
        ref={triggerRef}
        type="button"
        variant="ghost"
      >
        <span>{selected.name}</span>
        <ChevronDown aria-hidden="true" className="size-4" />
      </Button>
      {open ? (
        <div
          aria-label="Доступные модели"
          className="absolute left-0 top-full z-30 mt-2 w-72 rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-lg"
          role="menu"
        >
          {models.map((model, index) => (
            <button
              className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
              key={model.id}
              onClick={() => {
                onChange(model.id);
                setOpen(false);
              }}
              ref={(element) => {
                itemRefs.current[index] = element;
              }}
              role="menuitem"
              type="button"
            >
              <span className="min-w-0 flex-1">
                <span className="block text-sm font-medium">{model.name}</span>
                <span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
                  {model.description}
                </span>
              </span>
              {model.id === modelId ? (
                <Check aria-hidden="true" className="mt-0.5 size-4" />
              ) : null}
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}