84 lines
3.5 KiB
JavaScript
84 lines
3.5 KiB
JavaScript
import * as React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* PromptChips — suggested prompts.
|
|
*
|
|
* Clicking a chip runs it immediately. A chip is already a complete question, so
|
|
* making the user press Enter afterwards adds a step that buys nothing.
|
|
*
|
|
* Chips always wrap onto as many lines as they need. The earlier horizontal-scroll
|
|
* variant is gone: a scroller can always leave a chip half-visible at the edge,
|
|
* and a half-visible button is a bug no amount of fade masking fixes. Wrapping
|
|
* makes clipping structurally impossible instead of merely unlikely.
|
|
*
|
|
* `max` caps how many are offered in a conversation, where vertical space belongs
|
|
* to the thread rather than to suggestions.
|
|
*
|
|
* A chip is shrink-to-fit up to the panel's width, so a long suggestion spends the
|
|
* whole row before it is allowed to take a second line. When it does take one, the
|
|
* radius stays at the single-line pill's 14px rather than following the height:
|
|
* `rounded-full` on a two-line chip resolves to half of 46px, and that corner is
|
|
* what turns the same component into something that reads as a card.
|
|
*
|
|
* Arrow keys move between chips, so the whole set is one tab stop.
|
|
*/
|
|
/** @param {any} props */
|
|
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
|
const chipRefs = React.useRef([]);
|
|
const visible = max ? prompts.slice(0, max) : prompts;
|
|
|
|
const onKeyDown = (event, index) => {
|
|
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
|
if (!delta) return;
|
|
const next = chipRefs.current[index + delta];
|
|
if (!next) return;
|
|
event.preventDefault();
|
|
next.focus();
|
|
};
|
|
|
|
if (!visible.length) return null;
|
|
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label="Suggested prompts"
|
|
className={cn(
|
|
'flex flex-wrap gap-1.5',
|
|
align === 'center' ? 'justify-center' : 'justify-start',
|
|
className
|
|
)}
|
|
>
|
|
{visible.map((prompt, i) => (
|
|
<button
|
|
/* Position, not label. Callers de-duplicate, but a follow-up list is
|
|
built per answer and two chips can legitimately read the same —
|
|
the key has to stay unique either way. */
|
|
key={`${i}:${prompt.label}`}
|
|
ref={(el) => { chipRefs.current[i] = el; }}
|
|
type="button"
|
|
onClick={() => onSelect(prompt)}
|
|
onKeyDown={(e) => onKeyDown(e, i)}
|
|
title={prompt.prompt}
|
|
className={cn(
|
|
/* 14px is the radius `rounded-full` already produces on a one-line
|
|
chip, pinned so a wrapped chip keeps the same corner. */
|
|
'flex max-w-full items-center rounded-[14px] border border-border bg-surface px-2.5 py-1',
|
|
'text-caption font-medium text-ink-2 shadow-xs text-left',
|
|
'transition-all duration-base ease-out',
|
|
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
|
|
'active:translate-y-0 active:shadow-xs',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
|
|
)}
|
|
>
|
|
{/* `pretty` rather than `balance`: the row should be spent before a
|
|
second line starts, and balancing would leave the pill wide with
|
|
two short lines inside it. Clamped so no suggestion can grow the
|
|
chip into a paragraph — the full text stays in `title`. */}
|
|
<span className="line-clamp-3 text-pretty">{prompt.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|