diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index a58364b..ed095cd 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -30,6 +30,7 @@ @source "../../../packages/code-block/src"; @source "../../../packages/color-picker/src"; @source "../../../packages/country-picker/src"; +@source "../../../packages/model-picker/src"; @source "../content"; /** diff --git a/apps/docs/app/probe/model-picker/page.tsx b/apps/docs/app/probe/model-picker/page.tsx new file mode 100644 index 0000000..835a938 --- /dev/null +++ b/apps/docs/app/probe/model-picker/page.tsx @@ -0,0 +1,115 @@ +"use client" + +/** + * Not a docs page — a viewing harness, kept out of `content/` so it never + * appears in the sidebar. + * + * The interesting states of this block are all *open* states: the panel, the + * details card beside it, the meters, a locked row. A headless screenshot + * cannot click, so the trigger is clicked programmatically once the page has + * settled, and `?state=` picks which arrangement to capture. + * + * /probe/model-picker?theme=dark + * /probe/model-picker?theme=light&state=filtered + * /probe/model-picker?state=locked + * /probe/model-picker?state=scrolled&y=34 + */ + +import { useEffect, useRef } from "react" +import { ModelPicker } from "@fern-ui/model-picker" +import { + ModelPickerDemo, + ModelPickerMinimal, + ModelPickerPill, +} from "@/components/demos/model-picker-demo" + +/** + * Real key events, not React state — the point of the harness is to exercise + * the same path a user takes, including the focus and scroll behaviour that a + * prop-driven "open" would skip entirely. + */ +function press(target: Element, key: string) { + target.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ) +} + +export default function ModelPickerProbe() { + const hostRef = useRef(null) + + useEffect(() => { + const state = new URLSearchParams(window.location.search).get("state") + const host = hostRef.current + if (!host) return + + const trigger = host.querySelector('[data-slot="trigger"]') + trigger?.click() + + // One frame is not enough: the panel positions from a measurement taken + // after it mounts, and the details card only appears once that resolves. + const timer = window.setTimeout(() => { + const list = document.querySelector('[data-slot="list"]') + const input = document.querySelector('[data-slot="search"]') + const keyTarget = input ?? list + if (!keyTarget) return + + // `scrolled` parks the list mid-scroll, which is where the section + // headings and the scroll fades have to be checked. `?y=` picks the + // offset. It sets scrollTop on a later tick and skips the arrow presses + // below, because the cursor's own scrollIntoView runs after them and + // snapped the list straight back — a scroll test that silently measured + // nothing. + if (state === "scrolled") { + window.setTimeout(() => { + const l = document.querySelector('[data-slot="list"]') + const y = new URLSearchParams(window.location.search).get("y") + if (l) l.scrollTop = Number(y ?? 70) + }, 200) + return + } + + if (state === "locked") { + // The locked row sits last in the catalogue. + press(keyTarget, "End") + press(keyTarget, "ArrowUp") + } else if (state === "filtered") { + const chips = document.querySelectorAll( + '[data-slot="facets"] button', + ) + chips[0]?.click() + } else { + press(keyTarget, "ArrowDown") + press(keyTarget, "ArrowDown") + } + }, 400) + + return () => window.clearTimeout(timer) + }, []) + + return ( +
+
+
+

+ open +

+
+ +
+
+ + {/* Closed states, so one shot shows both triggers against the panel. */} +
+

closed

+ + + +
+
+
+ ) +} diff --git a/apps/docs/components/demos/model-picker-demo.tsx b/apps/docs/components/demos/model-picker-demo.tsx new file mode 100644 index 0000000..ee9b077 --- /dev/null +++ b/apps/docs/components/demos/model-picker-demo.tsx @@ -0,0 +1,240 @@ +"use client" + +import { useState } from "react" +import { ModelPicker, type Model } from "@fern-ui/model-picker" + +/** + * Real models, so the block is exercised against the names and mark shapes it + * will actually meet — invented ones hide the problems worth finding. + * + * The package ships no catalogue: one goes stale in weeks, and a wrong price in + * a component library is worse than no price. These figures are published list + * prices per million input tokens at the time of writing, here to make the + * layout honest rather than to be quoted. + */ +const mark = (slug: string) => `https://cdn.simpleicons.org/${slug}` + +/** + * Two marks are served from `public/`, where the country picker's flags live. + * + * ChatGPT because OpenAI's mark was pulled from the simple-icons CDN — the + * failure any real catalogue eventually hits. The Wikimedia file is the full + * green tile, so it needs no `logoBackground`. Gemini because its mark is a + * white spark, illegible without the brand gradient behind it. + */ +const CHATGPT = "/model-logos/chatgpt.svg" +const GEMINI = "/model-logos/gemini.svg" +/** Gemini's own blue-to-red sweep, which is the mark's whole identity. */ +const GEMINI_TILE = "linear-gradient(135deg, #4285F4 0%, #9B72CB 50%, #D96570 100%)" + +const MODELS: Model[] = [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + provider: "Anthropic", + logo: mark("claude"), + group: "Frontier", + badge: "New", + description: + "Anthropic's most capable model. Holds a plan across long agentic runs without losing the thread.", + tags: ["vision", "reasoning", "tools", "long context"], + strengths: ["Long-horizon agentic work", "Careful with tools", "Strong at code"], + limitations: ["The most expensive option here", "Slower to first token"], + price: { amount: 5, symbol: "$", per: "1M" }, + speed: 2, + quality: 5, + }, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + provider: "Anthropic", + logo: mark("claude"), + group: "Frontier", + description: + "Most of Opus's judgement at a fraction of the latency. The one to start from.", + tags: ["vision", "reasoning", "tools", "long context"], + strengths: ["Fast enough for an interactive loop", "Strong at extraction"], + limitations: ["Gives up earlier than Opus on hard reasoning"], + price: { amount: 3, symbol: "$", per: "1M" }, + speed: 4, + quality: 4, + }, + { + id: "gpt-5-1", + name: "GPT-5.1", + provider: "OpenAI", + logo: CHATGPT, + group: "Frontier", + description: "OpenAI's flagship, with adjustable reasoning effort.", + tags: ["vision", "reasoning", "tools"], + strengths: ["Reasoning effort is tunable per call", "Broad tool ecosystem"], + limitations: ["Latency climbs sharply at high effort"], + price: { amount: 1.25, symbol: "$", per: "1M" }, + speed: 3, + quality: 5, + }, + { + id: "gemini-3-pro", + name: "Gemini 3 Pro", + provider: "Google", + logo: GEMINI, + logoBackground: GEMINI_TILE, + group: "Frontier", + description: + "The widest context window on offer, and native audio and video input.", + tags: ["vision", "reasoning", "tools", "long context"], + strengths: ["1M token context", "Handles audio and video natively"], + limitations: ["Slows noticeably past a few hundred thousand tokens"], + price: { amount: 2, symbol: "$", per: "1M" }, + speed: 3, + quality: 4, + }, + { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + provider: "Anthropic", + logo: mark("claude"), + group: "Fast and cheap", + description: + "Built for classification, routing, and anything you run a million times.", + tags: ["vision", "tools", "fast"], + strengths: ["Very low latency", "Cheap enough to run per request"], + limitations: ["Loses the thread on long multi-step work"], + price: { amount: 1, symbol: "$", per: "1M" }, + speed: 5, + quality: 3, + }, + { + id: "gpt-5-1-mini", + name: "GPT-5.1 mini", + provider: "OpenAI", + logo: CHATGPT, + group: "Fast and cheap", + description: "The small tier of GPT-5.1, for high-volume work.", + tags: ["vision", "tools", "fast"], + strengths: ["Cheapest hosted option here", "Reliable structured output"], + limitations: ["Weak on open-ended reasoning"], + price: { amount: 0.25, symbol: "$", per: "1M" }, + speed: 5, + quality: 2, + }, + { + id: "deepseek-v3", + name: "DeepSeek-V3", + provider: "DeepSeek", + logo: mark("deepseek"), + group: "Open weights", + description: "Open weights, self-hostable, and strong at code for the price.", + tags: ["reasoning", "tools", "open weights"], + strengths: ["Runs on your own hardware", "Predictable cost at volume"], + limitations: ["No vision", "Shorter context than the frontier models"], + price: { amount: 0.28, symbol: "$", per: "1M" }, + speed: 3, + quality: 3, + }, + { + id: "llama-4-maverick", + name: "Llama 4 Maverick", + provider: "Meta", + logo: mark("meta"), + group: "Open weights", + description: "Meta's open-weight multimodal model, widely mirrored by hosts.", + tags: ["vision", "tools", "open weights"], + strengths: ["Permissive licence", "Available from many providers"], + limitations: ["Behind the frontier on hard reasoning"], + price: { amount: 0.35, symbol: "$", per: "1M" }, + speed: 4, + quality: 2, + }, + { + id: "mistral-large", + name: "Mistral Large", + provider: "Mistral", + logo: mark("mistralai"), + group: "Open weights", + description: "European-hosted, with strong multilingual coverage.", + tags: ["reasoning", "tools"], + strengths: ["EU data residency", "Even across European languages"], + limitations: ["No vision"], + price: { amount: 2, symbol: "$", per: "1M" }, + speed: 3, + quality: 3, + }, + { + id: "o3-pro", + name: "o3-pro", + provider: "OpenAI", + logo: CHATGPT, + group: "Frontier", + description: + "Extended deliberate reasoning, for problems where being right matters more than being quick.", + tags: ["reasoning", "tools"], + strengths: ["Best results on hard proofs and long refactors"], + limitations: ["Can spend minutes before answering"], + price: { amount: 20, symbol: "$", per: "1M", from: true }, + speed: 1, + quality: 5, + disabled: true, + disabledReason: "Available on the Scale plan. Your workspace is on Team.", + }, +] + +export function ModelPickerDemo() { + const [model, setModel] = useState("claude-sonnet-5") + return +} + +/** `showPrice` brings the figures back, on the rows and on the trigger. */ +export function ModelPickerPrices() { + const [model, setModel] = useState("gpt-5-1") + return ( + + ) +} + +/** The compact trigger, as it would sit in a row of config controls. */ +export function ModelPickerPill() { + const [model, setModel] = useState("gemini-3-pro") + return ( + + ) +} + +/** Recents pinned above the catalogue, and no capability chips. */ +export function ModelPickerRecent() { + const [model, setModel] = useState("claude-haiku-4-5") + const [recent, setRecent] = useState(["claude-haiku-4-5", "gpt-5-1"]) + + return ( + { + setModel(id) + // Short: a "recent" section long enough to scroll is the catalogue + // again. + setRecent((r) => [id, ...r.filter((x) => x !== id)].slice(0, 3)) + }} + filterable={false} + /> + ) +} + +/** A short list: no search field, no chips, no detail column. */ +export function ModelPickerMinimal() { + const [model, setModel] = useState("claude-haiku-4-5") + return ( + + ) +} diff --git a/apps/docs/components/mdx.tsx b/apps/docs/components/mdx.tsx index 6c177d7..3d889fa 100644 --- a/apps/docs/components/mdx.tsx +++ b/apps/docs/components/mdx.tsx @@ -26,6 +26,13 @@ import { ColorPickerMinimal, ColorPickerModel, } from "@/components/demos/color-picker-demo" +import { + ModelPickerDemo, + ModelPickerMinimal, + ModelPickerPill, + ModelPickerPrices, + ModelPickerRecent, +} from "@/components/demos/model-picker-demo" /** * Components reachable from MDX without an import in every file. Demos are @@ -64,6 +71,11 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { CountryPickerDemo, CountryPickerPriority, CountryPickerPlain, + ModelPickerDemo, + ModelPickerPill, + ModelPickerPrices, + ModelPickerRecent, + ModelPickerMinimal, ...components, } } diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index 3fe65e1..c80db69 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -8,6 +8,7 @@ "---Inputs---", "color-picker", "country-picker", + "model-picker", "---Display---", "code-block" ] diff --git a/apps/docs/content/docs/components/model-picker.mdx b/apps/docs/content/docs/components/model-picker.mdx new file mode 100644 index 0000000..6a4a123 --- /dev/null +++ b/apps/docs/content/docs/components/model-picker.mdx @@ -0,0 +1,280 @@ +--- +title: Model Picker +description: An AI model picker with search, capability filters, recents and locked models. +--- + + + + + + + +```tsx +import { ModelPicker } from "@fern-ui/model-picker" + +const [model, setModel] = useState("claude-sonnet-5") + + +``` + + + + +Choosing a model is a decision, not a preference — the question is never "which +name do I like" but "which of these, for this job, at this price". So the panel +is built to answer that comparison rather than to list names: the price sits +beside every name, locked models stay visible and say why, and the capability +chips come from your own tags. + +Hover, selection, badge and fade treatments are taken from +[Country Picker](/docs/components/country-picker) rather than reinvented — a +block that picks its own selected-state tint is recognisably a different +person's work sitting next to the others. + +There is no bundled model list. One goes stale in weeks, and a wrong price +inside a component library is worse than no price at all. + +## Examples + +### The detail pane is opt-in + +`showDetails` adds a second pane beside the list with the active model's +description, meters and tradeoffs. It is off by default: the list on its own +answers the question most of the time, and a panel that doubles in width to show +prose nobody asked for is heavier than what it replaced. + +Where it is on, it is open from the moment the panel is. Revealing it on hover +was tried first and was wrong twice over — it made the information mouse-only, +and a dwell delay stacked on an enter transition meant the pane took the better +part of half a second to answer, which reads as the component thinking rather +than responding. Nothing that can lag is the only preview that never lags. + +### Meters are relative to your catalogue + +`speed` and `quality` take any scale you like — 1-5, tokens per second, a +percentage — and are normalised across the models you actually offer. "Fast" +has no meaning without a population, and a fixed tier invites every model to be +marked a four. + +Cost is normalised only within the models sharing a price denominator. Credits +per second and credits per image are different quantities; ranking them against +each other would put a cheap video model below an expensive image one for no +reason anyone could act on. + +```tsx +{ price: { amount: 3, symbol: "$", per: "1M" }, speed: 4, quality: 4 } +``` + +A `symbol` drops the unit noun everywhere: `$3/1M` says what "3 dollars per 1M" +says in a third of the width, on every row. `from: true` marks the figure as a +floor rather than the price — presenting a floor as exact is something a user +only discovers on their invoice. + +### Prices + +Off by default. Most of the time the choice is made on capability, and the +figure is one more thing to read past on every row — a column of prices also +quietly reframes the list as a menu, which is a stronger claim than a component +should make on its own. + + + + + + + +```tsx + +``` + + + + +### Compact trigger + +`variant="pill"` is the form for a config row sitting beside aspect, duration +and the rest. It stays 40px: it is the compact variant, but the floor for an +interactive target does not move because a control got smaller. + +It opens on click, not hover. Hover-opening a menu in a dense row fires on +every pass of the cursor, and means nothing at all on touch. + + + + + + + +```tsx + +``` + + + + +### Recents + +The two models someone alternates between are otherwise buried, and re-finding +them is the most repeated action this component has. `recent` pins them into a +leading section — but only while idle, since once someone is searching or +filtering they have said what they want, and pinning the same rows twice makes +a short result set look longer than it is. + +Persisting the list is yours to decide. A block that writes to `localStorage` +on its own is a surprise in someone else's app. + + + + + + + +```tsx + { + setModel(id) + setRecent((r) => [id, ...r.filter((x) => x !== id)].slice(0, 3)) + }} +/> +``` + + + + +### Capability filters + +The chips are derived from the `tags` on your models, never configured +separately — the two cannot drift out of step, and adding a tag cannot silently +fail to offer a filter for it. + +They narrow rather than widen: picking `vision` and then `fast` is someone +building up a requirement, and answering that with a longer list than they +started from reads as the control ignoring them. Chips that would empty the +list are disabled in place rather than removed, because a chip row that reflows +as you click through it puts the next chip under a moving target. Backspace on +an empty query removes the last one. + +### Locked models + +A model gated behind a plan keeps its row, keeps its details, and says why. The +cursor still lands on it so the reason is reachable; only selection is blocked. +A model nobody can see is a model nobody can upgrade for. + +```tsx +{ + disabled: true, + disabledReason: "Available on the Scale plan. Your workspace is on Team.", +} +``` + +### Short lists + +Search and chips appear on their own once the catalogue is long enough to need +them — a search box over four models is noise. + + + + + + + +```tsx + +``` + + + + +### Logos + +`logo` is a URL you supply, drawn straight into the row — no plate, no ring, no +box. A mark on the surface reads as part of the row; the same mark inside a +bordered tile reads as a favicon someone pasted in, and ten of them down a list +are ten little boxes competing with the names beside them. + +The tradeoff is real: a monochrome black mark is invisible on a dark panel, +because nothing here can know what is inside the file. `logoBackground` puts a +filled brand-coloured tile back for a mark that needs one. + +A model with no logo, or one whose URL fails, falls back to the provider's +initial. Not hypothetical — the demo above hit it the day a CDN dropped one of +the marks it was using. + +### Keyboard + +Arrows move, Home and End jump to the ends, PageUp and PageDown move by ten. +Enter selects, Escape closes, Tab closes and moves on. Backspace on an empty +query removes the last filter chip. The trigger opens on Enter, Space or +ArrowDown, and the list opens with the cursor on the current selection, scrolled +to it. + +## Installation + + + +Or copy these files into your project. No runtime dependencies beyond React; +every one of them is imported by the component. + + + + + + + + + + + + + + + + +## Props + + void", description: "Fires with the id and the full model record." }, + { name: "onOpenChange", type: "(open) => void", description: "Fires as the panel opens and closes." }, + { name: "recent", type: "string[]", description: "Ids pinned into a leading section, most recent first." }, + { name: "recentLabel", type: "string", default: '"Recent"', description: "Heading for that section." }, + { name: "variant", type: '"default" | "pill"', default: '"default"', description: "Compact trigger for a config row." }, + { name: "searchable", type: "boolean", default: "over 7 models", description: "Show the search field." }, + { name: "filterable", type: "boolean", default: "2+ tags", description: "Show the capability chips." }, + { name: "showDetails", type: "boolean", default: "false", description: "A second pane with description, meters and tradeoffs." }, + { name: "showLogos", type: "boolean", default: "true", description: "Provider marks, falling back to the provider initial." }, + { name: "showPrice", type: "boolean", default: "false", description: "Price badges on rows and the trigger. Off by default." }, + { name: "placeholder", type: "string", default: '"Select a model"', description: "Trigger text with nothing selected." }, + { name: "searchPlaceholder", type: "string", default: '"Search models"', description: "Placeholder inside the search field." }, + { name: "disabled", type: "boolean", default: "false", description: "Block interaction and dim the control." }, + { name: "label", type: "string", default: '"Model"', description: "Accessible name for the trigger and panel." }, + ]} +/> + +## The `Model` record + + diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/docs/package.json b/apps/docs/package.json index 30979c2..83bd583 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,6 +13,7 @@ "@fern-ui/button": "workspace:*", "@fern-ui/code-block": "workspace:*", "@fern-ui/country-picker": "workspace:*", + "@fern-ui/model-picker": "workspace:*", "@gravity-ui/icons": "^2.20.0", "@heroui/react": "^3.2.2", "clsx": "^2.1.1", diff --git a/apps/docs/public/model-logos/chatgpt.svg b/apps/docs/public/model-logos/chatgpt.svg new file mode 100644 index 0000000..2312a77 --- /dev/null +++ b/apps/docs/public/model-logos/chatgpt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/docs/public/model-logos/gemini.svg b/apps/docs/public/model-logos/gemini.svg new file mode 100644 index 0000000..1111255 --- /dev/null +++ b/apps/docs/public/model-logos/gemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 92701d9..89a50f8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/model-picker/LICENSE b/packages/model-picker/LICENSE new file mode 100644 index 0000000..46236cf --- /dev/null +++ b/packages/model-picker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 hyperwavelabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/model-picker/README.md b/packages/model-picker/README.md new file mode 100644 index 0000000..d0eab63 --- /dev/null +++ b/packages/model-picker/README.md @@ -0,0 +1,153 @@ +# @fern-ui/model-picker + +A model picker for React — search, capability filters, and a details card that +follows the keyboard. No runtime dependencies beyond React itself. + +```bash +bun add @fern-ui/model-picker +``` + +Import the stylesheet once, anywhere in your app: + +```tsx +import "@fern-ui/model-picker/styles.css" +``` + +The component is built from Tailwind utilities. Without the stylesheet the +markup is correct and nothing is styled — it fails quietly rather than loudly, +so it is worth checking first if a block looks wrong. + +Skip it only if you are copy-pasting the source into a Tailwind project of your +own, where your build generates the utilities already. + +## Usage + +The catalogue is yours — there is no bundled model list, because a model list +goes stale in weeks and yours is the only one that is true. + +```tsx +import { ModelPicker, type Model } from "@fern-ui/model-picker" + +const MODELS: Model[] = [ + { + id: "opus-4-8", + name: "Opus 4.8", + provider: "Anthropic", + description: "The strongest model here for long multi-step work.", + tags: ["vision", "reasoning", "200K context"], + strengths: ["Holds long context without drifting", "Careful with tools"], + limitations: ["Slower than the smaller models"], + price: { amount: 15, unit: "credit", per: "1M tokens" }, + speed: 2, + quality: 5, + }, + // … +] + +function Example() { + const [model, setModel] = useState("opus-4-8") + return +} +``` + +Uncontrolled works too — omit `value` and pass `defaultValue`. + +## What it does that a dropdown does not + +**The details card is driven by the cursor, not by hover.** Arrow through the +list and the card keeps up, so everything it says — what a model is good at, +what it costs, why it is locked — is reachable without a mouse. It picks the +side with room, and folds into the rows when there is no room either side. + +**The meters are relative to your catalogue.** `speed` and `quality` take any +scale you like and are normalised across the models you actually offer, because +"fast" has no meaning without a population. Cost is normalised only within the +models sharing a price denominator — credits-per-second and credits-per-image +are different quantities, and ranking one against the other tells a user +nothing they can act on. + +**Locked models stay visible.** A model gated behind a plan keeps its row and +its details, cannot be selected, and says why. A model nobody can see is a +model nobody can upgrade for. + +**Filters come from your tags.** The chips are derived, not configured, so they +cannot drift out of step with the catalogue. Chips that would empty the list +are disabled in place rather than removed — a chip row that reflows as you +click through it puts the next chip under a moving target. + +**Order is preserved.** The list is never sorted alphabetically; a curated +catalogue puts the model you want chosen first, and sorting throws that away. + +## Keyboard + +Arrows move, Home and End jump to the ends, PageUp and PageDown move by ten. +Enter selects, Escape closes, Tab closes and moves on. Backspace on an empty +query removes the last filter chip. The trigger opens on Enter, Space or +ArrowDown. + +## Props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `models` | `Model[]` | — | The catalogue. Order is preserved. | +| `value` | `string` | — | Controlled selection, as a model id. | +| `defaultValue` | `string` | — | Initial selection when uncontrolled. | +| `onChange` | `(id, model) => void` | — | Fires with the id and the full record. | +| `onOpenChange` | `(open) => void` | — | Fires as the panel opens and closes. | +| `recent` | `string[]` | — | Ids pinned into a leading section, most recent first. | +| `recentLabel` | `string` | `"Recent"` | Heading for that section. | +| `variant` | `"default" \| "pill"` | `"default"` | Compact trigger for a config row. | +| `searchable` | `boolean` | over 7 models | Show the search field. | +| `filterable` | `boolean` | 2+ tags | Show the capability chips. | +| `showDetails` | `boolean` | `true` | The card beside the list. | +| `showLogos` | `boolean` | `true` | Provider marks, with a drawn monogram fallback. | +| `showPrice` | `boolean` | `true` | Price badges on rows and the trigger. | +| `placeholder` | `string` | `"Select a model"` | Trigger text with nothing selected. | +| `searchPlaceholder` | `string` | `"Search models"` | Placeholder inside the search field. | +| `disabled` | `boolean` | `false` | Block interaction and dim the control. | +| `label` | `string` | `"Model"` | Accessible name for the trigger and panel. | + +## The `Model` record + +```ts +interface Model { + id: string + name: string + provider?: string + logo?: string + description?: string + strengths?: string[] + limitations?: string[] + tags?: string[] // searchable, and the source of the filter chips + badge?: string // "New", "Beta" + price?: { amount: number; unit?: string; per?: string; from?: boolean } + speed?: number // any scale — normalised across the catalogue + quality?: number + group?: string // section heading + disabled?: boolean + disabledReason?: string // a locked row with no reason is a dead end +} +``` + +`price.from` renders "from N". Use it when the number is a floor rather than +the price — presenting a floor as an exact figure is something a user only +discovers on their invoice. + +Types, `formatPrice` and `meterScale` are importable without React: + +```ts +import { formatPrice, type Model } from "@fern-ui/model-picker/model" +``` + +## Theming + +Every colour reads a `--fern-` custom property with a literal fallback, so the +block themes automatically where those variables exist and still renders +correctly where they do not. + +`--fern-surface`, `--fern-foreground`, `--fern-muted`, `--fern-default`, +`--fern-default-hover`, `--fern-focus`, `--fern-overlay-shadow`. + +## Licence + +MIT diff --git a/packages/model-picker/package.json b/packages/model-picker/package.json new file mode 100644 index 0000000..ba67449 --- /dev/null +++ b/packages/model-picker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@fern-ui/model-picker", + "version": "0.1.0", + "description": "An accessible, dependency-free AI model picker for React — search, capability filters, and a details card that follows the keyboard.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "files": [ + "dist" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./styles.css": "./dist/styles.css", + "./model": { + "types": "./dist/model.d.ts", + "import": "./dist/model.js", + "require": "./dist/model.cjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "rm -rf dist && tsup && tailwindcss -i src/styles.css -o dist/styles.css --minify", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run build && cd ../.. && node scripts/verify-packages.mjs" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.3.3", + "@types/react": "^19.0.8", + "react": "^19.0.0", + "tailwindcss": "^4.3.3", + "tsup": "^8.3.6", + "typescript": "^5.7.3" + }, + "keywords": [ + "react", + "model-picker", + "model-selector", + "ai", + "llm", + "combobox", + "accessible" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/model-picker/src/chrome.tsx b/packages/model-picker/src/chrome.tsx new file mode 100644 index 0000000..3c1199e --- /dev/null +++ b/packages/model-picker/src/chrome.tsx @@ -0,0 +1,238 @@ +"use client" + +/** The closed control, and the panel's search and filter chrome. */ + +import * as React from "react" +import type { Model } from "./model" +import { Logo, RowPrice } from "./parts" +import { ChevronIcon, CloseIcon, SearchIcon } from "./icons" + +const cn = (...classes: (string | false | undefined | null)[]) => + classes.filter(Boolean).join(" ") + +const SURFACE = "var(--fern-surface, #ffffff)" +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" + +export type TriggerVariant = "default" | "pill" + +/** + * The trigger. `pill` is the compact form for a config row, `default` is + * standalone. + * + * Neither opens on hover: that fires on every pass of the cursor across a dense + * row and means nothing at all on touch. The pill stays 40px — the floor for an + * interactive target does not move because a control got smaller. + */ +export const Trigger = React.forwardRef< + HTMLButtonElement, + { + id: string + popoverId: string + open: boolean + label: string + variant: TriggerVariant + selected?: Model + placeholder: string + showLogos: boolean + showPrice: boolean + disabled: boolean + focusRing: string + onToggle: () => void + } +>(function Trigger( + { + id, + popoverId, + open, + label, + variant, + selected, + placeholder, + showLogos, + showPrice, + disabled, + focusRing, + onToggle, + }, + ref, +) { + const pill = variant === "pill" + + return ( + + ) +}) + +/** The search field, with its icon overlaid rather than in a wrapper box. + * Markup and metrics are the country picker's, unchanged. */ +export const SearchField = React.forwardRef< + HTMLInputElement, + { + listId: string + activeId?: string + placeholder: string + value: string + onChange: (value: string) => void + onClear: () => void + focusRing: string + } +>(function SearchField( + { listId, activeId, placeholder, value, onChange, onClear, focusRing }, + ref, +) { + return ( +
+ + + + onChange(event.target.value)} + className={cn( + "h-10 w-full rounded-xl bg-[var(--fern-default,#ebebec)] pr-9 pl-9", + "text-[15px] text-[var(--fern-foreground,#18181b)] outline-hidden", + "placeholder:text-[var(--fern-muted,#71717a)]", + focusRing, + )} + /> + {value && ( + + )} +
+ ) +}) + +/** + * Capability chips, on one line that scrolls. Wrapping them stacked seven into + * a block taller than three model rows — chrome outweighing what it filters. + * The edge fades so a sliced chip reads as more to come, not as a clipping bug. + */ +export function FacetChips({ + facets, + active, + live, + onToggle, +}: { + facets: string[] + active: string[] + live: Set + onToggle: (tag: string) => void +}) { + if (!facets.length) return null + return ( +
+
+ {facets.map((tag) => { + const on = active.includes(tag) + const dead = !live.has(tag) + return ( + + ) + })} +
+ +
+ ) +} diff --git a/packages/model-picker/src/details.tsx b/packages/model-picker/src/details.tsx new file mode 100644 index 0000000..f087567 --- /dev/null +++ b/packages/model-picker/src/details.tsx @@ -0,0 +1,183 @@ +"use client" + +/** The column that grows out of the panel's edge. Driven by the cursor, so it + * is reachable with the keyboard — a hover-only submenu is not. */ + +import * as React from "react" +import { formatPrice, type Meters, type Model } from "./model" +import { LockIcon } from "./icons" + +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" +const ACCENT = "var(--fern-focus, #0485f7)" + +export const DETAIL_WIDTH = 296 +/** A model with nine listed strengths has, in practice, none. The catalogue's + * own order decides which three matter. */ +const MAX_BULLETS = 3 +const SEGMENTS = 5 + +/** + * Segments rather than a continuous bar: a bar at the bottom of the range reads + * as a half-loaded progress bar, where segments still say "one out of five". + * + * Never transitioned — an eased fill is still growing toward the previous model + * while the cursor is two rows on. + */ +function Meter({ label, value }: { label: string; value: number }) { + const filled = Math.max(1, Math.round(value * SEGMENTS)) + return ( +
+ + {label} + + + {Array.from({ length: SEGMENTS }, (_, i) => ( + + ))} + +
+ ) +} + +function Bullets({ items, sign }: { items: string[]; sign: "+" | "–" }) { + return ( +
    + {items.map((item) => ( +
  • + + {sign} + + {item} +
  • + ))} +
+ ) +} + +function Label({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +export function DetailsCard({ + model, + meters, + /** How many models the meters are relative to. */ + population, + logo, +}: { + model: Model + meters: Meters + population: number + logo: React.ReactNode +}) { + const price = formatPrice(model.price) + const hasMeters = + meters.speed != null || meters.quality != null || meters.cost != null + + return ( + // Hidden from assistive tech: all of it is already announced through the + // active row's label and description, and exposing it as well reads the + // name twice on every arrow press. Spacing is looser than the list, which + // is scanned rather than read. +
+
+ {logo} +
+

+ {model.name} +

+ {model.provider && ( +

+ {model.provider} +

+ )} +
+ {/* In the header, not a footer — as a footer it sits below the fold on + any model with three strengths. */} + {price && ( + + {price} + + )} +
+ + {model.disabled && ( +

+ + + + {model.disabledReason ?? "Not available on your current plan."} +

+ )} + + {model.description && ( +

+ {model.description} +

+ )} + + {hasMeters && ( +
+ {meters.quality != null && } + {meters.speed != null && } + {meters.cost != null && } + {/* Said out loud: a filled scale with no stated population reads as + an absolute score. */} +

+ Relative to the {population} models here +

+
+ )} + + {!!model.strengths?.length && ( +
+ + +
+ )} + + {!!model.limitations?.length && ( +
+ + +
+ )} + +
+ ) +} diff --git a/packages/model-picker/src/empty.tsx b/packages/model-picker/src/empty.tsx new file mode 100644 index 0000000..7dcef94 --- /dev/null +++ b/packages/model-picker/src/empty.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { EmptyMark } from "./icons" + +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" + +/** + * Names what would match, rather than stopping at "no results". The suggestions + * are real facets from the catalogue, so they are guaranteed to return + * something — a dead end offering a second dead end is worse than the first. + */ +export function EmptyState({ + query, + facets, + onReset, +}: { + query: string + facets: string[] + onReset: () => void +}) { + return ( +
+ + + +

+ {query.trim() + ? `No models match “${query.trim()}”` + : "No models match these filters"} +

+

+ {facets.length ? ( + <> + Try a provider, or a capability like{" "} + {facets.slice(0, 2).map((tag, index) => ( + + {index > 0 && " or "} + {tag} + + ))} + . + + ) : ( + "Try a model name, or the provider that makes it." + )} +

+ +
+ ) +} diff --git a/packages/model-picker/src/filters.ts b/packages/model-picker/src/filters.ts new file mode 100644 index 0000000..0e7d80b --- /dev/null +++ b/packages/model-picker/src/filters.ts @@ -0,0 +1,56 @@ +import type { Model } from "./model" + +/** + * Derived from the catalogue, not configured — a separate `filters` prop goes + * stale the moment a model gains a tag, and fails invisibly. + * + * Ordered by how many models carry each tag: a facet matching one model is a + * search, not a filter. + */ +export function facets(models: Model[]): string[] { + const counts = new Map() + const firstSeen = new Map() + for (const [index, model] of models.entries()) { + for (const tag of model.tags ?? []) { + counts.set(tag, (counts.get(tag) ?? 0) + 1) + if (!firstSeen.has(tag)) firstSeen.set(tag, index) + } + } + return [...counts.keys()].sort( + (a, b) => + counts.get(b)! - counts.get(a)! || firstSeen.get(a)! - firstSeen.get(b)!, + ) +} + +/** + * Facets narrow, they do not widen. Picking "vision" then "fast" is someone + * building up a requirement; answering with a longer list than they started + * from reads as the control ignoring them. + */ +export function applyFacets(models: Model[], active: string[]): Model[] { + if (!active.length) return models + return models.filter((model) => + active.every((tag) => model.tags?.includes(tag)), + ) +} + +/** + * Which facets still return something. Dead ones are disabled rather than + * hidden — a chip row that reflows as you click through it puts the next chip + * under a moving target. + */ +export function liveFacets( + models: Model[], + active: string[], + all: string[], +): Set { + const live = new Set() + for (const tag of all) { + if (active.includes(tag)) { + live.add(tag) + continue + } + if (applyFacets(models, [...active, tag]).length) live.add(tag) + } + return live +} diff --git a/packages/model-picker/src/icons.tsx b/packages/model-picker/src/icons.tsx new file mode 100644 index 0000000..edf25c7 --- /dev/null +++ b/packages/model-picker/src/icons.tsx @@ -0,0 +1,152 @@ +/** Inline SVGs — the package ships no icon dependency. */ + +const iconProps = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +} + +export function SearchIcon() { + return ( + + + + + ) +} + +export function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ) +} + +export function CheckIcon({ size = 15 }: { size?: number }) { + return ( + + + + ) +} + +export function CloseIcon({ size = 12 }: { size?: number }) { + return ( + + + + ) +} + +/** Locked rows. A shackle that reads at 12px — closed, not ajar. */ +export function LockIcon({ size = 12 }: { size?: number }) { + return ( + + + + + ) +} + +/** Strengths. */ +export function PlusIcon({ size = 13 }: { size?: number }) { + return ( + + + + ) +} + +/** Limitations. Deliberately a minus rather than a warning triangle — a + * tradeoff is not an alert, and the triangle made every model look broken. */ +export function MinusIcon({ size = 13 }: { size?: number }) { + return ( + + + + ) +} + +/** + * Empty-state mark: two tilted cards behind a chip. + * + * Drawn rather than shipped as an image — the packages ship no assets, and a + * raster mark cannot follow the theme. Colours are tints of the foreground so + * they move the right way on both: darker on light, lighter on dark. + */ +export function EmptyMark() { + const CARD = 100 + const scale = 0.6 + const tint = (percent: number) => + `color-mix(in oklab, var(--fern-foreground, #18181b) ${percent}%, var(--fern-surface, #ffffff))` + + return ( +
+
+
+
+ + + + +
+
+ ) +} diff --git a/packages/model-picker/src/index.ts b/packages/model-picker/src/index.ts new file mode 100644 index 0000000..d0c9077 --- /dev/null +++ b/packages/model-picker/src/index.ts @@ -0,0 +1,4 @@ +export { ModelPicker } from "./model-picker" +export type { ModelPickerProps } from "./model-picker" +export { formatPrice, meterScale } from "./model" +export type { Meters, Model, ModelPrice } from "./model" diff --git a/packages/model-picker/src/keyboard.ts b/packages/model-picker/src/keyboard.ts new file mode 100644 index 0000000..70a4a2c --- /dev/null +++ b/packages/model-picker/src/keyboard.ts @@ -0,0 +1,89 @@ +import type * as React from "react" + +export interface KeyContext { + open: boolean + disabled: boolean + count: number + /** Empty query, used to decide whether Backspace belongs to the text. */ + queryEmpty: boolean + setOpen: (open: boolean) => void + setActiveIndex: React.Dispatch> + select: () => void + /** Removes the last active facet chip. */ + popFacet: () => void + close: () => void +} + +/** + * Arrows move one, Page keys ten, Home and End jump. Enter selects, Escape + * closes, Tab closes and moves on. + * + * Backspace on an empty query removes the last facet chip, as every token input + * does. Guarded on the query being empty — deleting a filter mid-word is a + * worse failure than not offering the shortcut. + */ +export function handleKeys( + event: React.KeyboardEvent, + { + open, + disabled, + count, + queryEmpty, + setOpen, + setActiveIndex, + select, + popFacet, + close, + }: KeyContext, +) { + if (disabled) return + + if (!open) { + if (["ArrowDown", "Enter", " "].includes(event.key)) { + event.preventDefault() + setOpen(true) + } + return + } + + switch (event.key) { + case "ArrowDown": + event.preventDefault() + setActiveIndex((i) => Math.min(i + 1, count - 1)) + break + case "ArrowUp": + event.preventDefault() + setActiveIndex((i) => Math.max(i - 1, 0)) + break + case "Home": + event.preventDefault() + setActiveIndex(0) + break + case "End": + event.preventDefault() + setActiveIndex(count - 1) + break + case "PageDown": + event.preventDefault() + setActiveIndex((i) => Math.min(i + 10, count - 1)) + break + case "PageUp": + event.preventDefault() + setActiveIndex((i) => Math.max(i - 10, 0)) + break + case "Enter": + event.preventDefault() + select() + break + case "Backspace": + if (queryEmpty) popFacet() + break + case "Escape": + event.preventDefault() + close() + break + case "Tab": + close() + break + } +} diff --git a/packages/model-picker/src/model-picker.tsx b/packages/model-picker/src/model-picker.tsx new file mode 100644 index 0000000..714cc4a --- /dev/null +++ b/packages/model-picker/src/model-picker.tsx @@ -0,0 +1,449 @@ +"use client" + +/** + * The picker's shell. Matching is in `./search`, faceting in `./filters`, the + * record and its metrics in `./model`, the visible list in `./use-model-list`, + * geometry in `./use-panel-geometry`, glyphs in `./icons`, leaves in `./parts` + * and `./empty`, trigger and chrome in `./chrome`, the detail pane in + * `./details`. + */ + +import * as React from "react" +import { createPortal } from "react-dom" +import type { Model } from "./model" +import { handleKeys } from "./keyboard" +import { useOpenState, useScrollEdges } from "./use-open-state" +import { useAnchoredPosition } from "./use-anchored-position" +import { usePanelGeometry } from "./use-panel-geometry" +import { useModelList } from "./use-model-list" +import { Logo, Row, ScrollFade, SectionHeader } from "./parts" +import { EmptyState } from "./empty" +import { FacetChips, SearchField, Trigger, type TriggerVariant } from "./chrome" +import { DETAIL_WIDTH, DetailsCard } from "./details" + +const cn = (...classes: (string | false | undefined | null)[]) => + classes.filter(Boolean).join(" ") + +const HAIRLINE = + "color-mix(in oklab, var(--fern-foreground, #18181b) 9%, transparent)" + +export interface ModelPickerProps + extends Omit, "onChange" | "onSelect"> { + /** The catalogue. Order is preserved — put the model you want chosen first. */ + models: Model[] + /** Controlled selection, as a model id. */ + value?: string + /** Initial selection when uncontrolled. */ + defaultValue?: string + /** Fires with the id and the full model record. */ + onChange?: (id: string, model: Model) => void + onOpenChange?: (open: boolean) => void + /** + * Ids pinned into a leading section, most recent first. Persisting them is + * the host's decision, not the component's — a block that writes to + * localStorage on its own is a surprise in someone else's app. + */ + recent?: string[] + recentLabel?: string + /** Compact trigger for a config row, or the standalone control. */ + variant?: TriggerVariant + /** Text shown on the trigger with nothing selected. */ + placeholder?: string + searchPlaceholder?: string + /** Defaults on once the catalogue is long enough to need it. */ + searchable?: boolean + /** Capability chips derived from tags. Defaults on when there are tags. */ + filterable?: boolean + /** + * A second pane with the active model's description, meters and tradeoffs. + * + * Off by default: the list alone answers the question most of the time. Where + * it is on it is open from the moment the panel is — no hover, no reveal + * delay — and folds into the rows when the viewport cannot hold both. + */ + showDetails?: boolean + showLogos?: boolean + /** + * Price beside each name, and on the trigger. Off by default: a column of + * prices reframes the list as a menu, which is a stronger claim than a + * component should make on its own. + */ + showPrice?: boolean + disabled?: boolean + /** Accessible name for the trigger and the panel. */ + label?: string +} + +export const ModelPicker = React.forwardRef( + function ModelPicker( + { + models, + value, + defaultValue, + onChange, + onOpenChange, + recent, + recentLabel = "Recent", + variant = "default", + placeholder = "Select a model", + searchPlaceholder = "Search models", + searchable, + filterable, + showDetails = false, + showLogos = true, + showPrice = false, + disabled = false, + label = "Model", + className, + ...props + }, + forwardedRef, + ) { + const [open, setOpen] = React.useState(false) + const { rendered, shown, reducedMotion, panelRef: attachPanel } = + useOpenState(open) + const [query, setQuery] = React.useState("") + const [activeFacets, setActiveFacets] = React.useState([]) + const [activeIndex, setActiveIndex] = React.useState(0) + const [internal, setInternal] = React.useState(defaultValue) + + const selectedId = value ?? internal + const listId = React.useId() + const triggerId = React.useId() + const popoverId = React.useId() + + const rootRef = React.useRef(null) + const triggerRef = React.useRef(null) + const inputRef = React.useRef(null) + const listRef = React.useRef(null) + const panelRef = React.useRef(null) + + const position = useAnchoredPosition(triggerRef, rendered, 420) + const geometry = usePanelGeometry( + position, + DETAIL_WIDTH, + showDetails, + shown, + reducedMotion, + ) + const { edges, measure: measureEdges } = useScrollEdges(listRef, rendered && !!geometry) + + const selected = models.find((m) => m.id === selectedId) + const { items, facets, live, meters } = useModelList( + models, + query, + activeFacets, + recent, + recentLabel, + ) + + const showSearch = searchable ?? models.length > 7 + const showFacets = (filterable ?? facets.length >= 2) && facets.length > 0 + + // Reset the cursor when the result set changes, or it can point past the end. + const [lastKey, setLastKey] = React.useState("") + const resultKey = `${query}|${activeFacets.join(",")}` + if (resultKey !== lastKey) { + setLastKey(resultKey) + setActiveIndex(0) + } + + const activeModel = items[activeIndex]?.model + + /** + * One element per section, each carrying its own heading. + * + * `index` stays flat across sections — it is the keyboard cursor's position + * in `items` and must not restart per group. + */ + const groups = React.useMemo(() => { + const out: { heading?: string; rows: { model: Model; index: number }[] }[] = + [] + items.forEach((item, index) => { + if (item.heading || out.length === 0) { + out.push({ heading: item.heading, rows: [] }) + } + out[out.length - 1]!.rows.push({ model: item.model, index }) + }) + return out + }, [items]) + // The pane holds its width even with nothing to show. Letting the panel + // narrow on an empty search and widen again on the next keystroke makes the + // whole object twitch under a cursor that has not moved. + const withDetail = !!geometry?.withDetail + + const commit = (model: Model) => { + // Locked rows keep the cursor so the pane can explain them, but a click + // must not quietly do nothing that looks like a selection. + if (model.disabled) return + if (value === undefined) setInternal(model.id) + onChange?.(model.id, model) + setOpen(false) + onOpenChange?.(false) + setQuery("") + } + + const setOpenState = (next: boolean) => { + setOpen(next) + onOpenChange?.(next) + } + + const close = () => { + setOpenState(false) + setQuery("") + } + + // Close on an outside pointer. The panel is portalled, so it is not inside + // rootRef and has to be checked separately. + React.useEffect(() => { + if (!open) return + const onPointer = (event: PointerEvent) => { + const target = event.target as Node + if (rootRef.current?.contains(target)) return + if (panelRef.current?.contains(target)) return + close() + } + document.addEventListener("pointerdown", onPointer) + return () => document.removeEventListener("pointerdown", onPointer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // Focus on open, and start the cursor on the current selection rather than + // row 0 — opening a list scrolled away from what is chosen makes someone + // hunt for the thing they already picked. + React.useEffect(() => { + if (!open) return + if (showSearch) inputRef.current?.focus() + else listRef.current?.focus() + const index = items.findIndex((item) => item.model.id === selectedId) + setActiveIndex(index > -1 ? index : 0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // Keep the active row in view. `block: "nearest"` so it scrolls the minimum + // rather than yanking the row to the centre on every arrow press. + React.useEffect(() => { + if (!open) return + listRef.current + ?.querySelector(`[data-index="${activeIndex}"]`) + ?.scrollIntoView({ block: "nearest" }) + }, [activeIndex, open, position?.maxHeight]) + + const onKeyDown = (event: React.KeyboardEvent) => + handleKeys(event, { + open, + disabled, + count: items.length, + queryEmpty: query === "", + setOpen: setOpenState, + setActiveIndex, + select: () => { + const model = items[activeIndex]?.model + if (model) commit(model) + }, + popFacet: () => setActiveFacets((f) => f.slice(0, -1)), + close, + }) + + const focusRing = + "outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--fern-focus,#0485f7)]/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--fern-surface,#ffffff)]" + + // With no room for the second pane the descriptions fold into the rows — + // narrower, but nothing is hidden behind the thing that explains it. + const inlineDetails = showDetails && !!geometry && !geometry.withDetail + + return ( +
{ + rootRef.current = node + if (typeof forwardedRef === "function") forwardedRef(node) + else if (forwardedRef) forwardedRef.current = node + }} + data-slot="model-picker" + className={cn( + "relative", + variant === "pill" ? "inline-flex" : "w-80", + className, + )} + onKeyDown={onKeyDown} + {...props} + > + setOpenState(!open)} + /> + + {rendered && + position && + geometry && + createPortal( +
{ + panelRef.current = node + attachPanel(node) + }} + id={popoverId} + data-slot="popover" + data-placement={position.placement} + role="dialog" + aria-label={label} + // One surface split by a hairline, not two slabs with a gutter: + // a gutter has no radius for the panes to be concentric with. + className="fixed z-50 flex overflow-hidden rounded-2xl bg-[var(--fern-surface,#ffffff)] p-1" + style={{ + ...geometry.vertical, + left: geometry.left, + width: geometry.width, + // Scales from the trigger it belongs to, not its own centre. + transformOrigin: + position.placement === "bottom" ? "top center" : "bottom center", + ...geometry.surface, + }} + > +
+ + {showSearch && ( +
+ { + setQuery("") + inputRef.current?.focus() + }} + focusRing={focusRing} + /> +
+ )} + + + {showFacets && ( +
+ + setActiveFacets((f) => + f.includes(tag) + ? f.filter((t) => t !== tag) + : [...f, tag], + ) + } + /> +
+ )} + + {/* min-h-0 is what makes the list the part that scrolls. */} +
+ {edges.top && } +
+ {items.length === 0 ? ( + { + setQuery("") + setActiveFacets([]) + inputRef.current?.focus() + }} + /> + ) : ( + groups.map((group, groupIndex) => ( +
+ {group.heading && ( + + )} + {group.rows.map(({ model, index }) => ( + + ))} +
+ )) + )} +
+ {edges.bottom && } +
+
+ + {withDetail && ( +
+ {activeModel && ( + } + /> + )} +
+ )} +
, + document.body, + )} +
+ ) + }, +) diff --git a/packages/model-picker/src/model.ts b/packages/model-picker/src/model.ts new file mode 100644 index 0000000..a9e2b00 --- /dev/null +++ b/packages/model-picker/src/model.ts @@ -0,0 +1,161 @@ +/** + * The model record, its price formatting, and the catalogue-relative metrics + * behind the meters. No React, so a server component — or a pricing page, or a + * test — can import this without pulling the picker in. That is why it is its + * own entry in `tsup.config.ts` and in `exports`. + */ + +export interface ModelPrice { + amount: number + /** + * Written before the figure — "$4/1M". With one, the unit noun is dropped + * everywhere: "$4" says what "4 dollars" says in a third of the width. + */ + symbol?: string + /** + * Singular. Pluralised naively with an "s" at 2 or more, which is correct + * for "credit", "token" and "request" — pass an already-plural noun if your + * unit is irregular. + */ + unit?: string + /** + * The denominator, written short: "sec", "image", "1M tokens". It lands in a + * badge beside the model name, so "generation" pushes the name out of a + * narrow trigger where "image" does not. + */ + per?: string + /** + * Renders "from N", for a figure that is a floor rather than the price. + * Presenting a floor as exact is something a user discovers on their invoice. + */ + from?: boolean +} + +export interface Model { + /** Stable id. This is what `onChange` reports and `value` matches against. */ + id: string + /** What the user reads. */ + name: string + /** Shown under the name and searchable — "Anthropic", "Google". */ + provider?: string + /** + * Provider mark. Optional by design: a model without one falls back to its + * initial on the same tile, never to another provider's icon. + */ + logo?: string + /** + * The tile behind the mark — any CSS background, gradients included. For a + * monochrome mark that would otherwise be invisible on one theme: pair it + * with a white version of the logo. + */ + logoBackground?: string + /** One or two lines. The reason someone would pick this model. */ + description?: string + strengths?: string[] + limitations?: string[] + /** Capabilities. Searchable, and the source of the filter chips. */ + tags?: string[] + /** A short status word beside the name: "New", "Beta", "Preview". */ + badge?: string + price?: ModelPrice + /** + * Any scale you like — 1-5, tokens/sec, a percentage. Meters normalise + * against the rest of the catalogue, so only the ordering matters. + */ + speed?: number + quality?: number + /** Section heading. Groups appear in the order they first occur. */ + group?: string + /** + * Unavailable on this plan or for this input. The row stays visible and the + * cursor still lands on it — a model you cannot see is a model you cannot + * upgrade for — but it cannot be selected. + */ + disabled?: boolean + /** Why it is unavailable. A locked row with no reason is a dead end. */ + disabledReason?: string +} + +/** + * "from 4 credits/sec", "2 credits/image", "6 credits". + * + * One format for the badge and the details card both, so the number a user + * compares in the list is the same number they read after opening it. + */ +export function formatPrice(price: ModelPrice | undefined): string | null { + if (!price || !Number.isFinite(price.amount)) return null + const prefix = price.from ? "from " : "" + const per = price.per ? `/${price.per}` : "" + if (price.symbol) return `${prefix}${price.symbol}${price.amount}${per}` + const unit = price.unit ?? "credit" + const noun = price.amount === 1 ? unit : `${unit}s` + return `${prefix}${price.amount} ${noun}${per}` +} + +/** + * The row form, without the unit noun — the noun is identical on every line and + * costs more width than the model names have. The card spells it out in full. + */ +export function compactPrice(price: ModelPrice | undefined): string | null { + if (!price || !Number.isFinite(price.amount)) return null + const per = price.per ? `/${price.per}` : "" + return `${price.from ? "~" : ""}${price.symbol ?? ""}${price.amount}${per}` +} + +export interface Meters { + /** 0-1, or null when the catalogue gives nothing to compare against. */ + speed: number | null + quality: number | null + /** Already inverted: a full bar is cheap, because full always reads as good. */ + cost: number | null +} + +const normalise = (value: number, values: number[]): number | null => { + if (values.length < 2) return null + const min = Math.min(...values) + const max = Math.max(...values) + // Every model scoring the same says nothing, and a row of full bars reads as + // a claim rather than the absence of one. + if (max === min) return null + return (value - min) / (max - min) +} + +/** + * A `model -> meters` lookup for one catalogue. + * + * Relative, not absolute: "fast" has no meaning without a population, and a + * fixed 1-5 tier invites a vendor to mark everything a 4. + * + * Cost is normalised only within models sharing a price denominator — + * per-second and per-image are different quantities, and ranking them against + * each other tells a user nothing they can act on. + */ +export function meterScale(models: Model[]): (model: Model) => Meters { + const speeds = models.map((m) => m.speed).filter((v): v is number => v != null) + const qualities = models + .map((m) => m.quality) + .filter((v): v is number => v != null) + + const byUnit = new Map() + for (const model of models) { + if (model.price == null) continue + const key = `${model.price.unit ?? "credit"}/${model.price.per ?? ""}` + if (!byUnit.has(key)) byUnit.set(key, []) + byUnit.get(key)!.push(model.price.amount) + } + + return (model) => { + let cost: number | null = null + if (model.price) { + const key = `${model.price.unit ?? "credit"}/${model.price.per ?? ""}` + const peers = byUnit.get(key) ?? [] + const ratio = normalise(model.price.amount, peers) + cost = ratio == null ? null : 1 - ratio + } + return { + speed: model.speed == null ? null : normalise(model.speed, speeds), + quality: model.quality == null ? null : normalise(model.quality, qualities), + cost, + } + } +} diff --git a/packages/model-picker/src/parts.tsx b/packages/model-picker/src/parts.tsx new file mode 100644 index 0000000..cb5a318 --- /dev/null +++ b/packages/model-picker/src/parts.tsx @@ -0,0 +1,338 @@ +"use client" + +/** The list's presentational leaves, kept out of the shell so the component + * file reads as composition. */ + +import * as React from "react" +import { compactPrice, type Model } from "./model" +import { matchRange } from "./search" +import { CheckIcon, LockIcon } from "./icons" + +const cn = (...classes: (string | false | undefined | null)[]) => + classes.filter(Boolean).join(" ") + +const SURFACE = "var(--fern-surface, #ffffff)" +const FG = "var(--fern-foreground, #18181b)" +const MUTED = "var(--fern-muted, #71717a)" + +/** + * Fade at a scroll boundary, so rows dissolve into the edge instead of being + * sliced by it. Anywhere content scrolls under something — a sticky header, the + * end of a panel — the hard cut reads as a rendering fault. + */ +export function ScrollFade({ side }: { side: "top" | "bottom" }) { + return ( +
+ ) +} + +/** + * Bolds the matched run inside a label. + * + * Weight rather than a background tint: the row already uses background to say + * where the cursor is and what is selected, and a third meaning painted in the + * same channel is one meaning too many. + */ +export function Highlight({ text, query }: { text: string; query: string }) { + const range = matchRange(text, query) + if (!range) return <>{text} + const [start, end] = range + return ( + <> + {text.slice(0, start)} + + {text.slice(start, end)} + + {text.slice(end)} + + ) +} + +/** + * The provider mark, drawn straight into the row — no plate or ring, which + * would read as a favicon pasted in and compete with the name beside it. + * + * A monochrome black mark is therefore invisible on a dark panel; nothing here + * can know what is inside the file, so `logoBackground` exists for brands that + * need a filled tile. A missing or broken logo falls back to the provider's + * initial rather than a shared placeholder, which would remove the one thing + * the eye uses to find a row again. + */ +export function Logo({ + model, + size = 22, + eager = false, +}: { + model: Model + size?: number + eager?: boolean +}) { + const [failed, setFailed] = React.useState(false) + + // Reset when the model changes, or the trigger keeps a previous model's + // failure and shows an initial for a logo that would have loaded. + const [lastId, setLastId] = React.useState(model.id) + if (lastId !== model.id) { + setLastId(model.id) + setFailed(false) + } + + const filled = !!model.logoBackground + const plate: React.CSSProperties = filled + ? { + background: model.logoBackground, + boxShadow: "inset 0 0 0 1px rgb(0 0 0 / 0.12)", + } + : {} + + if (model.logo && !failed) { + return ( + setFailed(true)} + data-slot="logo" + className="shrink-0 rounded-full object-contain" + style={{ + width: size, + height: size, + // A filled tile needs the mark inset off its own edge; a bare mark + // wants the whole box, or it renders smaller than its neighbours. + padding: filled ? size * 0.2 : 0, + ...plate, + }} + /> + ) + } + + return ( + + {(model.provider ?? model.name).trim().charAt(0).toUpperCase()} + + ) +} + +/** + * Built like the country picker's dial badge, so the two blocks read as one + * family. + * + * The fill is a tint of the foreground, not `--fern-default` — that is also the + * hovered-row background, and a badge painted in it disappears on exactly the + * row you are pointing at. + */ +export function RowPrice({ model }: { model: Model }) { + const label = compactPrice(model.price) + if (!label) return null + return ( + + {label} + + ) +} + +const ROW_SKIP = (tall: boolean): React.CSSProperties => ({ + // Lets the browser skip layout and paint for rows outside the scrollport, + // which is what keeps a long catalogue cheap without a virtualiser and its + // scroll-restoration problems. The intrinsic size supplies the height the + // row would have had, so the scrollbar does not jump as rows render. + contentVisibility: "auto", + containIntrinsicSize: tall ? "auto 56px" : "auto 40px", +}) + +export function Row({ + model, + index, + id, + query, + active, + selected, + showLogo, + showPrice, + /** No room for the detail column, so the row carries the description. */ + inlineDetails, + onSelect, + onHover, +}: { + model: Model + index: number + id: string + query: string + active: boolean + selected: boolean + showLogo: boolean + showPrice: boolean + inlineDetails: boolean + onSelect: (model: Model) => void + onHover: (index: number) => void +}) { + const locked = !!model.disabled + const sub = inlineDetails ? (model.description ?? model.provider) : undefined + + return ( +
onSelect(model)} + onPointerMove={() => onHover(index)} + style={ROW_SKIP(!!sub)} + className={cn( + // The country picker's row geometry exactly, so the two stacked in one + // app are the same object. `min-h-10` is the one addition: its 38px row + // is under fern's 40px floor for an interactive target. + "flex min-h-10 items-center gap-2.5 rounded-lg px-2 py-2", + // Two distinct states: the keyboard/pointer cursor is a neutral wash, + // the current selection is tinted with the accent — otherwise "where I + // am" and "what I chose" are indistinguishable. + active && !selected && "bg-[var(--fern-default,#ebebec)]", + selected && + "bg-[color-mix(in_oklab,var(--fern-focus,#0485f7)_12%,transparent)]", + selected && + active && + "bg-[color-mix(in_oklab,var(--fern-focus,#0485f7)_20%,transparent)]", + locked ? "cursor-not-allowed" : "cursor-pointer", + )} + > + {showLogo && ( + + + + )} + + + + + + + {model.badge && } + {/* Beside the name, like the badge — at the row's end it reads as a + status of the row rather than of the model. */} + {locked && ( + + + + )} + + {sub && ( + + {sub} + + )} + + + {showPrice && } + + {/* After the badge, as in the country picker. A reserved slot would keep + the badge column still but leave a permanent gap on every row. */} + {selected && ( + + + + )} + + {/* Read after the name when the cursor lands, so the reason a row cannot + be chosen reaches a screen reader at the moment it matters — the + detail column beside the list is visual reinforcement and is hidden. */} + {(locked || model.description) && ( + + {locked ? (model.disabledReason ?? "Unavailable") : model.description} + + )} +
+ ) +} + +/** Status word beside a name — "New", "Beta". Quiet enough not to outweigh the + * name it is attached to. */ +export function Badge({ label }: { label: string }) { + return ( + + {label} + + ) +} + +/** + * Sticky section heading. Its background fades out over the last stretch so a + * row scrolling underneath dissolves rather than being cut in half by the edge. + */ +/** + * Section heading. Deliberately not sticky, unlike the country picker's letter + * header. + * + * A pinned heading either lets the row behind show through it or guillotines + * that row at an invisible line, and two headings stack when the last section + * is too short to push the previous one out. That cost is worth paying for 198 + * countries, where losing the current letter loses your place; a catalogue of a + * dozen models is one flick from end to end. + */ +export function SectionHeader({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} diff --git a/packages/model-picker/src/search.ts b/packages/model-picker/src/search.ts new file mode 100644 index 0000000..5eae34a --- /dev/null +++ b/packages/model-picker/src/search.ts @@ -0,0 +1,127 @@ +import type { Model } from "./model" + +const fold = (value: string) => value.toLowerCase().trim() + +/** + * Scores a model against a query. Higher is better; 0 means no match. + * + * Ranked rather than filtered, so typing a provider gathers its models at the + * top instead of leaving them wherever the catalogue happened to put them. + */ +export function score(model: Model, query: string): number { + const q = fold(query) + if (!q) return 1 + + const name = fold(model.name) + const id = fold(model.id) + const provider = model.provider ? fold(model.provider) : "" + + if (id === q || name === q) return 100 + if (name.startsWith(q)) return 85 + // A provider match outranks a mid-name one: "google" should gather Google's + // models above a model that merely mentions it in a tag. + if (provider === q) return 80 + if (provider.startsWith(q)) return 70 + if (model.tags?.some((tag) => fold(tag) === q)) return 65 + if (name.includes(q)) return 50 + if (provider.includes(q)) return 40 + if (model.tags?.some((tag) => fold(tag).includes(q))) return 30 + if (id.includes(q)) return 20 + return 0 +} + +/** + * The visible list. + * + * With no query the catalogue's own order is preserved exactly. Unlike a + * country list, a model list is curated — the first entry is usually the one + * you want chosen, and alphabetising throws that away. Ties while searching + * break on the same original order, for the same reason. + */ +export function rank(models: Model[], query: string): Model[] { + if (!query.trim()) return models + return models + .map((model, index) => ({ model, index, s: score(model, query) })) + .filter((r) => r.s > 0) + .sort((a, b) => b.s - a.s || a.index - b.index) + .map((r) => r.model) +} + +/** + * Where a query lands in a string, so the row can bold it. + * + * Only the first occurrence, and only a contiguous run: highlighting every + * scattered letter of a fuzzy match turns the name into confetti and is harder + * to read than no highlight at all. + */ +export function matchRange(text: string, query: string): [number, number] | null { + const q = fold(query) + if (!q) return null + const start = fold(text).indexOf(q) + return start === -1 ? null : [start, start + q.length] +} + +export interface ListItem { + model: Model + /** Section heading to render above this row, if it opens one. */ + heading?: string +} + +/** + * Rows in section order, each section's heading attached to its first row. + * + * `recent` is pinned into its own leading section: in a catalogue big enough to + * need search, re-finding the two models someone alternates between is the most + * repeated action this component has. + */ +export function sections( + models: Model[], + recent: string[] | undefined, + recentLabel: string, +): ListItem[] { + const pinned: Model[] = [] + if (recent?.length) { + const byId = new Map(models.map((m) => [m.id, m] as const)) + for (const id of recent) { + const model = byId.get(id) + // Skipped rather than stubbed: a recents list outlives the catalogue it + // was recorded against. + if (model) pinned.push(model) + } + } + + const pinnedIds = new Set(pinned.map((m) => m.id)) + const rest = models.filter((m) => !pinnedIds.has(m.id)) + const grouped = rest.some((m) => m.group) + + const out: ListItem[] = pinned.map((model, index) => ({ + model, + heading: index === 0 ? recentLabel : undefined, + })) + + if (!grouped) { + // A single unnamed section still needs its heading when recents sit above + // it, or the rest of the catalogue reads as more recents. + for (const [index, model] of rest.entries()) { + out.push({ model, heading: index === 0 && pinned.length ? "All models" : undefined }) + } + return out + } + + const order: string[] = [] + const buckets = new Map() + for (const model of rest) { + const key = model.group ?? "Other" + if (!buckets.has(key)) { + buckets.set(key, []) + order.push(key) + } + buckets.get(key)!.push(model) + } + for (const key of order) { + for (const [index, model] of buckets.get(key)!.entries()) { + out.push({ model, heading: index === 0 ? key : undefined }) + } + } + return out +} diff --git a/packages/model-picker/src/styles.css b/packages/model-picker/src/styles.css new file mode 100644 index 0000000..514bd5c --- /dev/null +++ b/packages/model-picker/src/styles.css @@ -0,0 +1,19 @@ +/** + * The stylesheet shipped to consumers who install rather than copy-paste. + * + * Preflight is deliberately not imported. `@import "tailwindcss"` would pull + * Tailwind's base reset in with it, and a component library that resets its + * host's margins, headings and form controls is a bug, not a feature — only + * the theme variables and the utilities this component actually uses belong + * here. + * + * Built by @tailwindcss/cli into dist/styles.css. `@source` points at the + * component source so only the utilities in use are emitted. + */ + +@layer theme, base, components, utilities; + +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +@source "./"; diff --git a/packages/model-picker/src/use-anchored-position.ts b/packages/model-picker/src/use-anchored-position.ts new file mode 100644 index 0000000..be04f2c --- /dev/null +++ b/packages/model-picker/src/use-anchored-position.ts @@ -0,0 +1,83 @@ +"use client" + +import * as React from "react" + +export interface AnchoredPosition { + top: number + left: number + width: number + /** Which way it opened, so the panel can scale from the right edge. */ + placement: "top" | "bottom" + maxHeight: number + /** + * Carried here rather than read from `window` at render, so the details card + * — which picks its side from the room left over — recomputes on resize off + * the same measurement pass as the panel, instead of holding a width from + * whenever it last happened to render. + */ + viewportWidth: number +} + +const GAP = 8 +const MARGIN = 12 + +/** + * Positions a panel against a trigger, in viewport coordinates. + * + * The panel is portalled to `document.body`, so an ancestor with + * `overflow: hidden` cannot clip it — inside a card, a preview box or a modal, + * an absolutely-positioned dropdown gets cut off, and no amount of z-index + * fixes it because clipping happens before stacking. + * + * Flips above the trigger when the space below cannot hold the panel, and + * reports the height actually available so the list can scroll rather than + * overflow the viewport. + */ +export function useAnchoredPosition( + triggerRef: React.RefObject, + open: boolean, + desiredHeight: number, +): AnchoredPosition | null { + const [position, setPosition] = React.useState(null) + React.useEffect(() => { + if (!open) return + const trigger = triggerRef.current + if (!trigger) return + + const measure = () => { + const rect = trigger.getBoundingClientRect() + const below = window.innerHeight - rect.bottom - GAP - MARGIN + const above = rect.top - GAP - MARGIN + // Only flip when below genuinely cannot hold it and above is roomier — + // flipping for a few pixels is more disorienting than a shorter list. + const flip = below < Math.min(desiredHeight, 240) && above > below + + setPosition({ + top: flip ? rect.top - GAP : rect.bottom + GAP, + left: rect.left, + width: rect.width, + placement: flip ? "top" : "bottom", + maxHeight: Math.max(160, Math.min(desiredHeight, flip ? above : below)), + viewportWidth: window.innerWidth, + }) + } + + measure() + // Measuring once is not enough: the trigger can still be settling as the + // panel opens, leaving it narrower than its own trigger until something + // else forces a re-measure. + const observer = new ResizeObserver(measure) + observer.observe(trigger) + // `capture` so it also fires for scrolls inside any ancestor container, + // which is the case that silently leaves the panel behind. + window.addEventListener("scroll", measure, true) + window.addEventListener("resize", measure) + return () => { + observer.disconnect() + window.removeEventListener("scroll", measure, true) + window.removeEventListener("resize", measure) + } + }, [open, desiredHeight, triggerRef]) + + return position +} diff --git a/packages/model-picker/src/use-model-list.ts b/packages/model-picker/src/use-model-list.ts new file mode 100644 index 0000000..8469452 --- /dev/null +++ b/packages/model-picker/src/use-model-list.ts @@ -0,0 +1,56 @@ +"use client" + +import * as React from "react" +import { meterScale, type Meters, type Model } from "./model" +import { rank, sections, type ListItem } from "./search" +import { applyFacets, facets as deriveFacets, liveFacets } from "./filters" + +export interface ModelList { + items: ListItem[] + facets: string[] + live: Set + meters: (model: Model) => Meters +} + +/** + * The list the panel renders: faceted, ranked, sectioned, and the facet state + * that goes with it. + * + * Kept out of the component so the "which models appear, in what order" rule + * is one testable pure pipeline rather than four memos interleaved with + * refs and portals. + */ +export function useModelList( + models: Model[], + query: string, + activeFacets: string[], + recent: string[] | undefined, + recentLabel: string, +): ModelList { + const facets = React.useMemo(() => deriveFacets(models), [models]) + + const live = React.useMemo( + () => liveFacets(models, activeFacets, facets), + [models, activeFacets, facets], + ) + + /** + * Meters scale against the whole catalogue, not the filtered view. Filtering + * to the three fastest models and then showing them as slow, medium and fast + * would rescale the bars under the user without anything having changed + * about the models themselves. + */ + const meters = React.useMemo(() => meterScale(models), [models]) + + const items = React.useMemo(() => { + const narrowed = applyFacets(models, activeFacets) + const ranked = rank(narrowed, query) + // Recents are a shortcut through the idle list. Once someone is searching + // or filtering they have said what they want, and pinning the same rows + // twice makes a short result set look longer than it is. + const idle = !query.trim() && !activeFacets.length + return sections(ranked, idle ? recent : undefined, recentLabel) + }, [models, activeFacets, query, recent, recentLabel]) + + return { items, facets, live, meters } +} diff --git a/packages/model-picker/src/use-open-state.ts b/packages/model-picker/src/use-open-state.ts new file mode 100644 index 0000000..03d8fdb --- /dev/null +++ b/packages/model-picker/src/use-open-state.ts @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" + +/** + * A softer ease-out than the usual (0.23, 1, 0.32, 1) — it decelerates over a + * longer tail, so the panel settles instead of snapping to a stop. + * + * Still ease-out, not ease-in. ease-in withholds movement for the first + * frames, which is precisely when the user is watching for a response, so it + * reads as lag however smooth the rest of the curve is. + */ +export const EASE_OUT = "cubic-bezier(0.16, 1, 0.3, 1)" +/** Enter has room to settle; exits stay quick, or leaving reads as reluctance. */ +export const ENTER_MS = 220 +export const EXIT_MS = 130 + +const REDUCED_MOTION = "(prefers-reduced-motion: reduce)" + +export function usePrefersReducedMotion() { + return React.useSyncExternalStore( + (cb) => { + const q = window.matchMedia(REDUCED_MOTION) + q.addEventListener("change", cb) + return () => q.removeEventListener("change", cb) + }, + () => window.matchMedia(REDUCED_MOTION).matches, + () => false, + ) +} + +/** + * Open/close with an exit transition. + * + * `rendered` keeps the panel in the DOM through its exit; `shown` drives the + * transition. Without the split there is no exit animation, because React + * unmounts before it can play. + */ +export function useOpenState(open: boolean) { + const reducedMotion = usePrefersReducedMotion() + const [rendered, setRendered] = React.useState(false) + const [shown, setShown] = React.useState(false) + + React.useEffect(() => { + if (open) { + setRendered(true) + return + } + setShown(false) + const timer = window.setTimeout( + () => setRendered(false), + reducedMotion ? 0 : EXIT_MS, + ) + return () => window.clearTimeout(timer) + }, [open, reducedMotion]) + + /** + * Attach to the panel. A ref callback runs with the node already in the DOM, + * so reading a layout property here forces the closed style to be committed + * before the open one is applied — which is what gives the transition a start + * state. Scheduling that with requestAnimationFrame instead is fragile: the + * frame can be cancelled by an effect re-run and the panel then mounts stuck + * at its closed style, invisible. + */ + const panelRef = React.useCallback( + (node: HTMLElement | null) => { + if (!node) return + void node.offsetHeight + setShown(true) + }, + [], + ) + + return { rendered, shown, reducedMotion, panelRef } +} + + +/** + * Which scroll edges have content beyond them. + * + * A fade that shows when there is nothing to scroll to is not a hint, it is a + * wash over the first row — so each edge reports independently and the fade + * only appears once it means something. + */ +export function useScrollEdges( + ref: React.RefObject, + /** + * True only once the scroller is in the DOM. `rendered` alone is a render too + * early — the panel waits for its anchor to resolve — so the effect runs + * against a null ref and never re-runs. + */ + active: boolean, +) { + const [edges, setEdges] = React.useState({ top: false, bottom: false }) + + const measure = React.useCallback(() => { + const el = ref.current + if (!el) return + const { scrollTop, scrollHeight, clientHeight } = el + const top = scrollTop > 1 + const bottom = scrollTop + clientHeight < scrollHeight - 1 + // Bail out when nothing changed. A fresh object is a new reference, so + // React re-renders, and the observer below re-measures after every render — + // an infinite loop, not a wasted render. + setEdges((prev) => + prev.top === top && prev.bottom === bottom ? prev : { top, bottom }, + ) + }, [ref]) + + /** + * Observed, not measured once after mount: a single measurement reads the + * list before flex has bounded it, `scrollHeight === clientHeight`, and no + * edge ever reports overflow. Nothing re-measures afterwards, because the + * only other trigger is a scroll on a list that looks complete. + */ + React.useEffect(() => { + const el = ref.current + if (!active || !el) return + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + // The scroller's own box stops changing once it is bounded; it is the + // content inside that grows and shrinks as results filter. + for (const child of Array.from(el.children)) observer.observe(child) + return () => observer.disconnect() + }, [active, measure, ref]) + + return { edges, measure } +} diff --git a/packages/model-picker/src/use-panel-geometry.ts b/packages/model-picker/src/use-panel-geometry.ts new file mode 100644 index 0000000..94de2d5 --- /dev/null +++ b/packages/model-picker/src/use-panel-geometry.ts @@ -0,0 +1,85 @@ +"use client" + +import * as React from "react" +import type { AnchoredPosition } from "./use-anchored-position" +import { EASE_OUT, ENTER_MS, EXIT_MS } from "./use-open-state" + +const MARGIN = 12 +/** + * Only ever widens a narrow trigger — a pill in a config row would otherwise + * open a 120px list. A standard trigger is wider than this, so the panel + * matches it exactly; narrower than its own control reads as misaligned. + */ +const MIN_LIST_WIDTH = 292 + +export interface PanelGeometry { + left: number + /** The whole panel, list plus detail column. */ + width: number + listWidth: number + /** Whether the detail column fits beside the list at all. */ + withDetail: boolean + vertical: React.CSSProperties + surface: React.CSSProperties +} + +/** + * Where the panel sits, and how it arrives. + * + * The detail column is part of the panel, not a second card beside it: two + * slabs with a gutter read as two windows, and the gutter has no radius for + * them to be concentric with. When the pair does not fit, the panel is simply + * the list and the descriptions fold into the rows. + */ +export function usePanelGeometry( + position: AnchoredPosition | null, + detailWidth: number, + wantsDetail: boolean, + shown: boolean, + reducedMotion: boolean, +): PanelGeometry | null { + return React.useMemo(() => { + if (!position) return null + + const listWidth = Math.max(position.width, MIN_LIST_WIDTH) + const available = position.viewportWidth - MARGIN * 2 + const withDetail = wantsDetail && listWidth + detailWidth <= available + const width = listWidth + (withDetail ? detailWidth : 0) + + return { + left: Math.max( + MARGIN, + Math.min(position.left, position.viewportWidth - MARGIN - width), + ), + width, + listWidth, + withDetail, + vertical: { + top: position.placement === "bottom" ? position.top : undefined, + bottom: + position.placement === "top" + ? window.innerHeight - position.top + : undefined, + // The bound goes on the panel; the list inside is a flex child that + // scrolls. Subtracting a guessed chrome height slices the last row the + // moment the chrome grows a line. + maxHeight: position.maxHeight, + }, + surface: { + boxShadow: + "var(--fern-overlay-shadow, 0 14px 28px 0 rgb(0 0 0 / 0.08), 0 2px 8px 0 rgb(0 0 0 / 0.06), 0 0 0 1px rgb(0 0 0 / 0.06))", + opacity: shown ? 1 : 0, + // Never from scale(0) — nothing in the real world appears out of + // nothing. 0.97 with a small rise reads as the panel arriving. + transform: reducedMotion + ? undefined + : shown + ? "scale(1) translateY(0)" + : `scale(0.97) translateY(${position.placement === "top" ? 6 : -6}px)`, + transitionProperty: reducedMotion ? "opacity" : "opacity, transform", + transitionDuration: `${shown ? ENTER_MS : EXIT_MS}ms`, + transitionTimingFunction: EASE_OUT, + }, + } + }, [position, detailWidth, wantsDetail, shown, reducedMotion]) +} diff --git a/packages/model-picker/tsconfig.json b/packages/model-picker/tsconfig.json new file mode 100644 index 0000000..db04394 --- /dev/null +++ b/packages/model-picker/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/model-picker/tsup.config.ts b/packages/model-picker/tsup.config.ts new file mode 100644 index 0000000..0608660 --- /dev/null +++ b/packages/model-picker/tsup.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from "tsup" + +const shared = { + format: ["esm", "cjs"] as const, + dts: true, + external: ["react", "react-dom"], +} + +/** + * Two passes, because the client boundary is per entry and tsup's `banner` is + * per config. Bundling them together marks the model record and its price + * formatting as client code, which hands a server component a client reference + * instead of the function. + */ +export default defineConfig([ + { + ...shared, + entry: { index: "src/index.ts", "model-picker": "src/model-picker.tsx" }, + clean: false, + splitting: true, + /** + * esbuild strips directives when it bundles, so the boundary is re-added + * here — and `treeshake` stays off because it runs rollup over esbuild's + * output afterwards, which drops the directive again and warns that it + * "was ignored". That warning was the only sign nothing shipped a boundary. + */ + treeshake: false, + banner: { js: '"use client";' }, + }, + { + ...shared, + // Types, price formatting and the meter scale. No React, no banner — + // importing them on the server is the reason this is a separate entry. + entry: { model: "src/model.ts" }, + clean: false, + splitting: false, + treeshake: true, + }, +]) diff --git a/scripts/verify-packages.mjs b/scripts/verify-packages.mjs index 96d965e..758a2fa 100644 --- a/scripts/verify-packages.mjs +++ b/scripts/verify-packages.mjs @@ -16,7 +16,7 @@ import { readFileSync, existsSync, readdirSync } from "node:fs" import { join } from "node:path" -const PACKAGES = ["button", "code-block", "color-picker", "country-picker"] +const PACKAGES = ["button", "code-block", "color-picker", "country-picker", "model-picker"] /** * Entries that must NOT carry a client boundary. They exist so a consumer can @@ -29,6 +29,7 @@ const PACKAGES = ["button", "code-block", "color-picker", "country-picker"] const PURE_ENTRIES = { "color-picker": ["./color"], "country-picker": ["./countries"], + "model-picker": ["./model"], button: [], "code-block": [], }