mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(design-system): add AI composer and compact card headers
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Buzz design system
|
||||
|
||||
The next Buzz web client starts with a living design system. `/design` is the
|
||||
visual overview; `/design/components` contains 54 searchable live examples;
|
||||
visual overview; `/design/components` contains 55 searchable live examples;
|
||||
`/design/compositions` shows product patterns and generated responses.
|
||||
|
||||
```sh
|
||||
|
||||
@@ -12,7 +12,7 @@ so it needs no mock native bridge or live relay.
|
||||
Rebuild after any source change. `pnpm exec playwright test --grep <name>` narrows
|
||||
a regression without replacing the full final pass.
|
||||
|
||||
Browser coverage binds the actual production catalog: all 54 examples, axe ARIA
|
||||
Browser coverage binds the actual production catalog: all 55 examples, axe ARIA
|
||||
checks in both modes, no external runtime requests, preserved loading labels,
|
||||
checkbox and switch keyboard activation, dialog focus trap and return, selection,
|
||||
command dispatch, form validation, multiline input, tabs, manual carousel,
|
||||
@@ -38,3 +38,8 @@ inspection without adding transition properties that could mask missing motion.
|
||||
Layout coverage verifies table alignment, native/custom scrollbar treatment, actual
|
||||
scrolling, pointer and keyboard resizing, grip sizing under zoom, and shared Glass
|
||||
tab motion, keyboard selection, and reduced motion.
|
||||
|
||||
Composer coverage exercises empty drafts, multiline and modifier handling, IME
|
||||
composition, pointer/keyboard send and stop, retained failed drafts and retry,
|
||||
attachment removal and limits, model controls, transcript preview, and 390/884/1440
|
||||
layouts. Card header spacing is checked against the production component.
|
||||
|
||||
247
desktop-next/src/features/design-system/catalog/composer.tsx
Normal file
247
desktop-next/src/features/design-system/catalog/composer.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { AIComposer, Button, IconButton, Select } from "@buzz/ui";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
function ComposerChoice({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: readonly { value: string; label: string }[];
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select.Root
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
if (next) onChange(next);
|
||||
}}
|
||||
items={options}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select.Trigger aria-label={label}>
|
||||
{label === "Action policy" && (
|
||||
<ShieldCheck aria-hidden="true" className="bui-icon" />
|
||||
)}
|
||||
<Select.Value />
|
||||
<Select.Icon>
|
||||
<ChevronDown aria-hidden="true" className="bui-icon" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Positioner sideOffset={8}>
|
||||
<Select.Popup>
|
||||
<Select.List>
|
||||
{options.map((option) => (
|
||||
<Select.Item key={option.value} value={option.value}>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
<Select.ItemIndicator>
|
||||
<Check aria-hidden="true" />
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.List>
|
||||
</Select.Popup>
|
||||
</Select.Positioner>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposerDemo() {
|
||||
const [value, setValue] = useState("");
|
||||
const [annotation, setAnnotation] = useState(true);
|
||||
const [files, setFiles] = useState<Array<{ id: string; file: File }>>([]);
|
||||
const [model, setModel] = useState("balanced");
|
||||
const [policy, setPolicy] = useState("ask");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [failNext, setFailNext] = useState(false);
|
||||
const [status, setStatus] = useState(
|
||||
"Local preview. No messages, files, or audio leave this page.",
|
||||
);
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className="bui-stack">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
aria-label="Choose composer attachments"
|
||||
disabled={running}
|
||||
onChange={(event) => {
|
||||
const incoming = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = "";
|
||||
if (
|
||||
files.length + incoming.length > 5 ||
|
||||
incoming.some((file) => file.size > 10 * 1024 * 1024)
|
||||
) {
|
||||
setAttachmentError(
|
||||
"Choose up to 5 files, each no larger than 10 MB.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAttachmentError("");
|
||||
setFiles((current) => [
|
||||
...current,
|
||||
...incoming.map((file) => ({ id: crypto.randomUUID(), file })),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
<AIComposer
|
||||
label="Prompt the assistant"
|
||||
placeholder="What would you like to build?"
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onAttach={() => fileInput.current?.click()}
|
||||
voice={{
|
||||
label: "Insert demo voice transcript",
|
||||
onToggle: () => {
|
||||
setValue("Help me refine this design system.");
|
||||
setStatus(
|
||||
"Sample transcript inserted. The microphone was not accessed.",
|
||||
);
|
||||
},
|
||||
}}
|
||||
generation={
|
||||
running
|
||||
? {
|
||||
onStop: () => {
|
||||
setRunning(false);
|
||||
setStatus("Preview stopped. You can write another prompt.");
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSubmit={(text) => {
|
||||
if (failNext) {
|
||||
setFailNext(false);
|
||||
throw new Error("Simulated submission failure");
|
||||
}
|
||||
setStatus(
|
||||
`Preview accepted: “${text}” · ${model} · ${policy} · ${files.length} attachments · ${annotation ? 1 : 0} annotations. No request was sent.`,
|
||||
);
|
||||
setValue("");
|
||||
setFiles([]);
|
||||
setRunning(true);
|
||||
}}
|
||||
context={({ disabled }) => (
|
||||
<>
|
||||
{annotation && (
|
||||
<span className="bui-chip">
|
||||
<MessageSquare aria-hidden="true" className="bui-icon" />1
|
||||
annotation
|
||||
<IconButton
|
||||
aria-label="Remove annotation"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => setAnnotation(false)}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
{files.map(({ id, file }) => (
|
||||
<span key={id} className="bui-chip">
|
||||
<FileText aria-hidden="true" className="bui-icon" />
|
||||
<span className="min-w-0 wrap-anywhere">{file.name}</span>
|
||||
<IconButton
|
||||
aria-label={`Remove attachment ${file.name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setFiles((current) =>
|
||||
current.filter((item) => item.id !== id),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</IconButton>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
controls={({ disabled }) => (
|
||||
<>
|
||||
<ComposerChoice
|
||||
label="Model"
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: "balanced", label: "Balanced" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
]}
|
||||
/>
|
||||
<ComposerChoice
|
||||
label="Action policy"
|
||||
value={policy}
|
||||
onChange={setPolicy}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: "ask", label: "Ask first" },
|
||||
{ value: "read", label: "Read only" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{attachmentError && (
|
||||
<p className="bui-field-error" role="alert">
|
||||
{attachmentError}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-caption text-secondary wrap-anywhere" role="status">
|
||||
{status}
|
||||
</p>
|
||||
<div className="bui-inline">
|
||||
{running ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRunning(false);
|
||||
setStatus("Preview complete. Ready for another prompt.");
|
||||
}}
|
||||
>
|
||||
Finish preview
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={failNext}
|
||||
onClick={() => setFailNext(!failNext)}
|
||||
>
|
||||
Simulate send failure
|
||||
</Button>
|
||||
)}
|
||||
{!annotation && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={running}
|
||||
onClick={() => setAnnotation(true)}
|
||||
>
|
||||
Restore annotation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BarChart,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
Carousel,
|
||||
EmptyState,
|
||||
Meter,
|
||||
@@ -26,10 +27,12 @@ export function CardDemo() {
|
||||
<Layers aria-hidden="true" />
|
||||
<Badge>Project</Badge>
|
||||
</div>
|
||||
<h3 className="text-heading">Build something together</h3>
|
||||
<p className="text-body text-secondary">
|
||||
A quiet container for a focused group of ideas.
|
||||
</p>
|
||||
<CardHeader>
|
||||
<h3 className="text-heading">Build something together</h3>
|
||||
<p className="text-body text-secondary">
|
||||
A quiet container for a focused group of ideas.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
role="link"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { ComposerDemo } from "./composer";
|
||||
import composerSource from "./composer.tsx?raw";
|
||||
import * as forms from "./forms";
|
||||
import formsSource from "./forms.tsx?raw";
|
||||
import * as overlays from "./overlays";
|
||||
@@ -18,6 +20,15 @@ export interface CatalogEntry {
|
||||
source: string;
|
||||
}
|
||||
export const CATALOG: readonly CatalogEntry[] = [
|
||||
{
|
||||
id: "ai-composer",
|
||||
name: "AI composer",
|
||||
description:
|
||||
"A multiline prompt with context, attachments, model controls, and send/stop states.",
|
||||
category: "Inputs",
|
||||
Preview: ComposerDemo,
|
||||
source: composerSource,
|
||||
},
|
||||
{
|
||||
id: "buttons",
|
||||
name: "Buttons",
|
||||
|
||||
@@ -21,7 +21,7 @@ for (const mode of ["light", "dark"] as const) {
|
||||
await page.goto("/design/components");
|
||||
if (mode === "dark")
|
||||
await page.getByRole("button", { name: "Switch to dark mode" }).click();
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(54);
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(55);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Saving", exact: true }),
|
||||
).toBeDisabled();
|
||||
@@ -141,7 +141,7 @@ test("filtering has an explicit empty-state recovery", async ({ page }) => {
|
||||
.fill("nothing-matches-this");
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Clear filters" }).click();
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(54);
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(55);
|
||||
});
|
||||
test("generated actions require a click, disable during streaming, and recover after invalid data", async ({
|
||||
page,
|
||||
@@ -286,8 +286,8 @@ test("styled navigation preserves link semantics and opens the component catalog
|
||||
}) => {
|
||||
await page.goto("/design");
|
||||
await page
|
||||
.getByRole("link", { name: "Explore 54 examples", exact: true })
|
||||
.getByRole("link", { name: "Explore 55 examples", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL("/design/components");
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(54);
|
||||
await expect(page.locator(".catalog-entry")).toHaveCount(55);
|
||||
});
|
||||
|
||||
152
desktop-next/tests/composer.spec.ts
Normal file
152
desktop-next/tests/composer.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { waitForAnimations } from "../../desktop/tests/helpers/animations";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/design/components#ai-composer");
|
||||
});
|
||||
|
||||
test("composer shares pointer and keyboard submission while preserving IME and modifiers", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt the assistant" });
|
||||
const send = page.getByRole("button", { name: "Send message", exact: true });
|
||||
await expect(send).toBeDisabled();
|
||||
await input.fill(" ");
|
||||
await expect(send).toBeDisabled();
|
||||
await input.fill("First line");
|
||||
await input.press("End");
|
||||
await input.press("Shift+Enter");
|
||||
await input.pressSequentially("Second line");
|
||||
await expect(input).toHaveValue("First line\nSecond line");
|
||||
for (const key of ["Control+Enter", "Meta+Enter", "Alt+Enter"]) {
|
||||
await input.press(key);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Stop response" }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
await input.dispatchEvent("compositionstart");
|
||||
await input.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Stop response" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await input.dispatchEvent("compositionend");
|
||||
await input.fill("Compose a useful response");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue("");
|
||||
await expect(input).toHaveAttribute("readonly", "");
|
||||
await expect(
|
||||
page.getByRole("combobox", { name: "Model", exact: true }),
|
||||
).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Stop response" }).click();
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill("Another prompt");
|
||||
await send.click();
|
||||
await page.getByRole("button", { name: "Finish preview" }).click();
|
||||
await expect(input).toBeEditable();
|
||||
});
|
||||
|
||||
test("failed sends retain the draft and allow an explicit retry", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt the assistant" });
|
||||
await input.fill("Keep this draft when sending fails");
|
||||
await page.getByRole("button", { name: "Simulate send failure" }).click();
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"Couldn’t send. Your draft is still here. Try again.",
|
||||
);
|
||||
await expect(input).toHaveValue("Keep this draft when sending fails");
|
||||
await expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
await input.press("Enter");
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Stop response" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("attachments, context, model controls, and transcript preview are functional", async ({
|
||||
page,
|
||||
}) => {
|
||||
const section = page.locator("#ai-composer");
|
||||
const chooserEvent = page.waitForEvent("filechooser");
|
||||
await section.getByRole("button", { name: "Add attachment" }).click();
|
||||
const chooser = await chooserEvent;
|
||||
await chooser.setFiles([
|
||||
{
|
||||
name: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Local attachment"),
|
||||
},
|
||||
]);
|
||||
await section
|
||||
.getByRole("button", { name: "Remove attachment notes.txt" })
|
||||
.click();
|
||||
await expect(section.getByText("notes.txt", { exact: true })).toHaveCount(0);
|
||||
await section.getByRole("button", { name: "Remove annotation" }).click();
|
||||
await expect(section.getByText("1 annotation", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await section.getByRole("combobox", { name: "Model", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Fast", exact: true }).click();
|
||||
await section.getByRole("combobox", { name: "Action policy" }).click();
|
||||
await page.getByRole("option", { name: "Read only", exact: true }).click();
|
||||
await section
|
||||
.getByRole("button", { name: "Insert demo voice transcript" })
|
||||
.click();
|
||||
await expect(section.getByRole("status")).toContainText(
|
||||
"The microphone was not accessed.",
|
||||
);
|
||||
await section
|
||||
.getByRole("button", { name: "Send message", exact: true })
|
||||
.click();
|
||||
await expect(section.getByRole("status")).toContainText(
|
||||
"fast · read · 0 attachments · 0 annotations",
|
||||
);
|
||||
await section.getByRole("button", { name: "Stop response" }).click();
|
||||
await section.locator('input[type="file"]').setInputFiles(
|
||||
Array.from({ length: 6 }, (_, i) => ({
|
||||
name: `file-${i}.txt`,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("demo"),
|
||||
})),
|
||||
);
|
||||
await expect(section.getByRole("alert")).toContainText(
|
||||
"Choose up to 5 files",
|
||||
);
|
||||
await expect(
|
||||
section.getByRole("button", { name: /^Remove attachment/ }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("card header is compact and composer fits narrow layouts", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await expect(page.locator("#card .bui-card-header")).toHaveCSS(
|
||||
"row-gap",
|
||||
"8px",
|
||||
);
|
||||
for (const width of [390, 884, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const composer = page.locator("#ai-composer .bui-composer");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(composer.locator(".bui-composer-footer")).toHaveCSS(
|
||||
"height",
|
||||
"40px",
|
||||
);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await composer.scrollIntoViewIfNeeded();
|
||||
await waitForAnimations(page);
|
||||
await composer.screenshot({
|
||||
path: testInfo.outputPath(`composer-${width}.png`),
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "Switch to dark mode" }).click();
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.locator("#ai-composer .bui-composer")
|
||||
.screenshot({ path: testInfo.outputPath("composer-dark.png") });
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
--control-sm: 2rem;
|
||||
--control-md: 2.5rem;
|
||||
--control-lg: 3.25rem;
|
||||
--control-multiline-min: 5rem;
|
||||
--control-multiline-max: 16rem;
|
||||
--size-scrollbar: 0.375rem;
|
||||
--size-grip-length: 1rem;
|
||||
--size-grip-thickness: 0.25rem;
|
||||
|
||||
@@ -7,6 +7,16 @@ export const FOUNDATION_GROUPS = [
|
||||
["control-sm", "2rem", "Small actions."],
|
||||
["control-md", "2.5rem", "Standard actions and inputs."],
|
||||
["control-lg", "3.25rem", "Prominent actions."],
|
||||
[
|
||||
"control-multiline-min",
|
||||
"5rem",
|
||||
"Minimum writing area for a multiline prompt.",
|
||||
],
|
||||
[
|
||||
"control-multiline-max",
|
||||
"16rem",
|
||||
"Maximum growing writing area before scrolling.",
|
||||
],
|
||||
[
|
||||
"size-scrollbar",
|
||||
"0.375rem",
|
||||
|
||||
@@ -79,6 +79,42 @@ Native scroll surfaces use thin scrollbars; custom ScrollArea bars share the
|
||||
length while retaining a larger pointer target. Table cells align to the reading
|
||||
edge, including row and column headings.
|
||||
|
||||
## AI composer
|
||||
|
||||
`AIComposer` is a controlled multiline prompt surface. It requires nonblank text,
|
||||
uses Enter to submit and Shift+Enter for newlines, and leaves IME composition and
|
||||
other modifier combinations alone. It grows between the shared multiline bounds,
|
||||
then scrolls with the system's thin scrollbar. Button and keyboard submissions use
|
||||
one path; pending submissions disable duplicate sends.
|
||||
|
||||
```tsx
|
||||
<AIComposer
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onSubmit={async (text) => {
|
||||
await acceptPrompt(text); // Host-owned request with timeout/retry policy.
|
||||
setDraft(""); // Clear only after acceptance; rejected requests retain the draft.
|
||||
}}
|
||||
onAttach={openFilePicker}
|
||||
context={({ disabled }) => <ContextChips disabled={disabled} />}
|
||||
controls={({ disabled }) => <ModelControls disabled={disabled} />}
|
||||
generation={isGenerating ? { onStop: stopGeneration } : undefined}
|
||||
/>
|
||||
```
|
||||
|
||||
The host supplies model/permission controls, context, and attachment behavior.
|
||||
Slots receive `disabled` and must apply it to their controls. Rejections show an
|
||||
inline retry message; the host must not clear the controlled draft before success.
|
||||
While generation is active, the stop action stays available even if editing is
|
||||
disabled. Optional `voice` supplies a labeled host callback; the component never
|
||||
requests microphone access or uploads data. Authentication, permission enforcement,
|
||||
model execution, file validation, speech capture, and network lifecycle belong to
|
||||
the host. The catalog demonstrates these seams locally, including a sample voice
|
||||
transcript and bounded attachment metadata; it sends nothing to a model.
|
||||
|
||||
Use `CardHeader` around a card title and description for an 8px gap at the default
|
||||
root size, while preserving the larger spacing between the card's other sections.
|
||||
|
||||
## Generated responses
|
||||
|
||||
`GeneratedResponse` accepts unknown input and validates it before rendering. The
|
||||
|
||||
170
packages/ui/src/composer.tsx
Normal file
170
packages/ui/src/composer.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ArrowUp, Mic, Plus, Square } from "lucide-react";
|
||||
import { IconButton } from "./button";
|
||||
import { cx } from "./classes";
|
||||
|
||||
/** Slots receive the same availability state as the input and submit action. */
|
||||
export interface ComposerSlotState {
|
||||
disabled: boolean;
|
||||
}
|
||||
/** A controlled draft. The host owns attachments, generation, and clearing after success. */
|
||||
export interface AIComposerProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
/** Resolves when the host accepts the prompt; rejects to preserve the draft and show retry feedback. */
|
||||
onSubmit: (text: string) => void | Promise<void>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
/** Context chips, filenames, or annotations; render removal controls disabled when indicated. */
|
||||
context?: (state: ComposerSlotState) => ReactNode;
|
||||
/** Model, reasoning, or permission controls, composed from the shared primitives. */
|
||||
controls?: (state: ComposerSlotState) => ReactNode;
|
||||
onAttach?: () => void;
|
||||
/** The host owns microphone permission, capture, and transcription. */
|
||||
voice?: { label: string; active?: boolean; onToggle: () => void };
|
||||
/** Presence means a response is running and guarantees an explicit stop action. */
|
||||
generation?: { onStop: () => void };
|
||||
className?: string;
|
||||
}
|
||||
/** Multiline AI input with one submission path, IME-safe keyboard handling, and retained failed drafts. */
|
||||
export function AIComposer({
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
label = "Message the assistant",
|
||||
placeholder = "Ask anything…",
|
||||
disabled = false,
|
||||
maxLength = 16000,
|
||||
context,
|
||||
controls,
|
||||
onAttach,
|
||||
voice,
|
||||
generation,
|
||||
className,
|
||||
}: AIComposerProps) {
|
||||
const helpId = useId();
|
||||
const errorId = useId();
|
||||
const composing = useRef(false);
|
||||
const submitting = useRef(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const locked = disabled || pending || !!generation;
|
||||
const canSend =
|
||||
!locked && value.trim().length > 0 && value.length <= maxLength;
|
||||
return (
|
||||
<form
|
||||
aria-label="AI composer"
|
||||
className={cx("bui-composer", className)}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!canSend || submitting.current || composing.current) return;
|
||||
submitting.current = true;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(value.trim());
|
||||
} catch {
|
||||
setError("Couldn’t send. Your draft is still here. Try again.");
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{context && (
|
||||
<div className="bui-composer-context">
|
||||
{context({ disabled: locked })}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
aria-label={label}
|
||||
aria-describedby={`${helpId}${error ? ` ${errorId}` : ""}`}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className="bui-composer-input"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onValueChange(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
readOnly={pending || !!generation}
|
||||
onCompositionStart={() => {
|
||||
composing.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composing.current = false;
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key !== "Enter" ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.nativeEvent.keyCode === 229 ||
|
||||
composing.current
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
<div className="bui-composer-footer">
|
||||
{onAttach && (
|
||||
<IconButton
|
||||
aria-label="Add attachment"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={locked}
|
||||
onClick={onAttach}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
<div className="bui-composer-controls">
|
||||
{controls?.({ disabled: locked })}
|
||||
</div>
|
||||
{voice && (
|
||||
<IconButton
|
||||
aria-label={voice.label}
|
||||
size="sm"
|
||||
aria-pressed={voice.active ?? false}
|
||||
variant="ghost"
|
||||
disabled={locked}
|
||||
onClick={voice.onToggle}
|
||||
>
|
||||
<Mic aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
{generation ? (
|
||||
<IconButton aria-label="Stop response" onClick={generation.onStop}>
|
||||
<Square aria-hidden="true" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
type="submit"
|
||||
aria-label={pending ? "Sending message" : "Send message"}
|
||||
disabled={!canSend}
|
||||
loading={pending}
|
||||
>
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<p id={helpId} className="bui-sr-only">
|
||||
Enter to send. Shift+Enter for a new line.
|
||||
</p>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="bui-field-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -55,3 +55,9 @@ export {
|
||||
type GeneratedView,
|
||||
type GeneratedViewResult,
|
||||
} from "./generated/schema";
|
||||
|
||||
export {
|
||||
AIComposer,
|
||||
type AIComposerProps,
|
||||
type ComposerSlotState,
|
||||
} from "./composer";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@import "@buzz/design-tokens/foundations.css";
|
||||
|
||||
@import "./styles/actions.css";
|
||||
@import "./styles/composer.css";
|
||||
@import "./styles/controls.css";
|
||||
@import "./styles/overlays.css";
|
||||
@import "./styles/surfaces.css";
|
||||
|
||||
75
packages/ui/src/styles/composer.css
Normal file
75
packages/ui/src/styles/composer.css
Normal file
@@ -0,0 +1,75 @@
|
||||
@layer components {
|
||||
.bui-composer {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-control-inset);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-control-gap);
|
||||
border: 1px solid var(--border-interactive);
|
||||
border-radius: var(--radius-overlay);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.bui-composer:focus-within {
|
||||
outline: 2px solid var(--ring-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.bui-composer-input {
|
||||
@apply text-body-lg;
|
||||
width: 100%;
|
||||
min-height: var(--control-multiline-min);
|
||||
max-height: var(--control-multiline-max);
|
||||
field-sizing: content;
|
||||
resize: none;
|
||||
padding: var(--space-control-gap);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bui-composer-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.bui-composer-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.bui-composer-input:disabled {
|
||||
color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bui-composer-context,
|
||||
.bui-composer-footer,
|
||||
.bui-composer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-control-gap);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.bui-composer-context:empty {
|
||||
display: none;
|
||||
}
|
||||
.bui-composer-footer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.bui-composer-footer > .bui-icon-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bui-composer-controls {
|
||||
flex: 1 1 0;
|
||||
gap: calc(var(--space-control-gap) / 2);
|
||||
}
|
||||
.bui-composer-controls .bui-select-trigger {
|
||||
@apply text-caption;
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding-inline: calc(var(--space-control-gap) / 2);
|
||||
gap: calc(var(--space-control-gap) / 2);
|
||||
}
|
||||
.bui-composer-context .bui-chip {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@
|
||||
padding: var(--space-panel-inset);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bui-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-control-gap);
|
||||
}
|
||||
.bui-card-header > * {
|
||||
margin: 0;
|
||||
}
|
||||
.bui-badge,
|
||||
.bui-chip {
|
||||
@apply text-meta;
|
||||
|
||||
@@ -5,6 +5,10 @@ import { cx } from "./classes";
|
||||
export function Card({ className, ...props }: ComponentProps<"section">) {
|
||||
return <section {...props} className={cx("bui-card", className)} />;
|
||||
}
|
||||
/** Groups a card heading and description with a compact shared rhythm. */
|
||||
export function CardHeader({ className, ...props }: ComponentProps<"div">) {
|
||||
return <div {...props} className={cx("bui-card-header", className)} />;
|
||||
}
|
||||
/** A concise status label; its text carries meaning in addition to color. */
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
|
||||
Reference in New Issue
Block a user