e8fa1ce4ea
Dark muted surfaces missed WCAG AA; action and tertiary tokens plus a 32-pair contract close that. Hit targets, remaining-count, and Enter-to-send follow the interaction audit without changing visual sizes. Co-authored-by: Cursor <cursoragent@cursor.com>
94 lines
2.5 KiB
TypeScript
94 lines
2.5 KiB
TypeScript
"use client";
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
import type { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from "react";
|
|
import { ArrowUp, Square } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { characterRemainingVisible } from "@/lib/character-remaining";
|
|
import {
|
|
composerDraftSnapshot,
|
|
serverComposerDraftSnapshot,
|
|
subscribeComposerDraft,
|
|
} from "@/lib/composer-draft";
|
|
|
|
export const composerRemainingId = "composer-character-remaining";
|
|
|
|
export function useComposerDraft(): string {
|
|
return useSyncExternalStore(
|
|
subscribeComposerDraft,
|
|
composerDraftSnapshot,
|
|
serverComposerDraftSnapshot,
|
|
);
|
|
}
|
|
|
|
type ChatComposerProps = {
|
|
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
|
|
readonly inputLabel: string;
|
|
readonly placeholder: string;
|
|
readonly maxLength: number;
|
|
readonly inputDisabled: boolean;
|
|
readonly submitLabel: string;
|
|
readonly submitBlocked: boolean;
|
|
readonly stopVisible: boolean;
|
|
readonly stopLabel: string;
|
|
readonly stopTitle: string;
|
|
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
|
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
readonly onStop: () => void;
|
|
};
|
|
|
|
export function ChatComposer({
|
|
inputRef,
|
|
inputLabel,
|
|
placeholder,
|
|
maxLength,
|
|
inputDisabled,
|
|
submitLabel,
|
|
submitBlocked,
|
|
stopVisible,
|
|
stopLabel,
|
|
stopTitle,
|
|
onSubmit,
|
|
onChange,
|
|
onKeyDown,
|
|
onStop,
|
|
}: ChatComposerProps) {
|
|
const draft = useComposerDraft();
|
|
const showRemaining = characterRemainingVisible(draft.length, maxLength);
|
|
|
|
return (
|
|
<form className="composer" onSubmit={onSubmit}>
|
|
<Textarea
|
|
ref={inputRef}
|
|
aria-label={inputLabel}
|
|
aria-describedby={showRemaining ? composerRemainingId : undefined}
|
|
placeholder={placeholder}
|
|
rows={1}
|
|
maxLength={maxLength}
|
|
disabled={inputDisabled}
|
|
value={draft}
|
|
onChange={onChange}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
{stopVisible ? (
|
|
<Button
|
|
className="composer-stop"
|
|
aria-label={stopLabel}
|
|
title={stopTitle}
|
|
size="icon"
|
|
type="button"
|
|
onClick={onStop}
|
|
>
|
|
<Square aria-hidden="true" />
|
|
</Button>
|
|
) : (
|
|
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
|
|
<ArrowUp aria-hidden="true" />
|
|
</Button>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|