quantization-picker / components /ModelInput.tsx
stevhliu's picture
stevhliu HF Staff
Replace the precision dropdown with a hover receipt
9b65d3b
Raw
History Blame Contribute Delete
4.34 kB
"use client";
import { useCallback, useRef, useState } from "react";
import type { Model } from "@/lib/data";
import { elideMiddle } from "./elide";
// The field is a fixed 420px with 14px of padding either side. It does not
// grow with the name: a field that resized would shove the rest of the
// sentence around on every keystroke.
const TEXT_PX = 420 - 28;
const TRACKING = "-1.6px"; // -0.04em at 40px
/**
* The canvas cannot resolve `var(--font-geist-sans)`, so take the font the
* browser actually resolved on the field itself.
*/
function fontOf(el: HTMLInputElement | null): string | null {
if (!el) return null;
const s = getComputedStyle(el);
return s.font || `${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
}
interface Props {
onResolve: (model: Model | null) => void;
}
type Status =
| { kind: "empty" }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ok" };
/**
* A free-text field for a Hub repo id. There are millions of models on the
* Hub, so there is no list to pick from — the id is typed, and the sizes come
* back from /api/model.
*/
export default function ModelInput({ onResolve }: Props) {
const [text, setText] = useState("");
const [status, setStatus] = useState<Status>({ kind: "empty" });
const [focused, setFocused] = useState(false);
const [font, setFont] = useState<string | null>(null);
// A callback ref runs at commit, which is where the resolved font can be read.
const attachInput = useCallback((el: HTMLInputElement | null) => {
if (el) setFont(fontOf(el));
}, []);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Only the newest lookup may write state; earlier ones lose the race.
const latest = useRef(0);
async function lookup(id: string, ticket: number) {
try {
const res = await fetch(`/api/model?id=${encodeURIComponent(id)}`);
const body = await res.json();
if (ticket !== latest.current) return;
if (!res.ok) {
setStatus({ kind: "error", message: body.error ?? "Lookup failed." });
onResolve(null);
return;
}
setStatus({ kind: "ok" });
onResolve(body as Model);
} catch {
if (ticket !== latest.current) return;
setStatus({ kind: "error", message: "Could not reach the Hub." });
onResolve(null);
}
}
function handleChange(next: string) {
setText(next);
clearTimeout(timer.current);
const id = next.trim();
const ticket = ++latest.current;
if (!id) {
setStatus({ kind: "empty" });
onResolve(null);
return;
}
setStatus({ kind: "loading" });
timer.current = setTimeout(() => lookup(id, ticket), 400);
}
// While the field is not being edited, a name too long for the box shows its
// middle elided so the tail — the part that names the checkpoint — stays
// visible. The input keeps the real value throughout.
const elided =
focused || !font ? text : elideMiddle(text, TEXT_PX, font, TRACKING);
const isElided = elided !== text;
return (
<span className="relative inline-flex flex-col">
<input
ref={attachInput}
aria-label="Model"
value={text}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="Qwen/Qwen3-32B"
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
title={isElided ? text : undefined}
className={`h-[52px] w-[420px] rounded-md bg-paper px-3.5 text-[40px] font-medium leading-[44px] tracking-[-0.04em] ring-raised outline-none placeholder:text-ash ${
isElided ? "text-transparent" : "text-obsidian"
}`}
/>
{isElided && (
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center text-[40px] font-medium leading-[44px] tracking-[-0.04em] text-obsidian"
>
{elided}
</span>
)}
<span
className="label absolute top-[56px] left-1 whitespace-nowrap text-graphite"
role={status.kind === "error" ? "alert" : undefined}
>
{status.kind === "loading" && "Looking up"}
{status.kind === "error" && status.message}
</span>
</span>
);
}